Merge pull request #155 from gamosoft/features/more-media-support
Features/more media support
This commit is contained in:
commit
9496c4fe57
|
|
@ -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,33 +1,56 @@
|
||||||
"""
|
"""
|
||||||
HTML Export Module for NoteDiscovery
|
HTML Export Module for NoteDiscovery
|
||||||
Generates standalone HTML files for notes with embedded images and styling.
|
Generates standalone HTML files for notes with embedded images and styling.
|
||||||
Used by both /api/export (download) and /public (sharing) endpoints.
|
Used by both /api/export (download) and /share (public sharing) endpoints.
|
||||||
|
|
||||||
|
Note: Only images are embedded as base64. Audio, video, and PDF files are
|
||||||
|
replaced with placeholder HTML since they would make exports too large.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
|
# Import shared media type definitions from utils to avoid duplication
|
||||||
|
from backend.utils import MEDIA_EXTENSIONS, get_media_type
|
||||||
|
|
||||||
def get_image_as_base64(image_path: Path) -> Optional[str]:
|
|
||||||
"""Read an image file and return it as a base64 data URL."""
|
def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]:
|
||||||
if not image_path.exists() or not image_path.is_file():
|
"""
|
||||||
|
Read a media file and return it as a base64 data URL.
|
||||||
|
Returns tuple of (base64_url, media_type) or None if failed.
|
||||||
|
"""
|
||||||
|
if not media_path.exists() or not media_path.is_file():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get MIME type
|
# Get MIME type
|
||||||
mime_type, _ = mimetypes.guess_type(str(image_path))
|
mime_type, _ = mimetypes.guess_type(str(media_path))
|
||||||
if not mime_type or not mime_type.startswith('image/'):
|
if not mime_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Determine media type
|
||||||
|
media_type = get_media_type(media_path.name)
|
||||||
|
if not media_type:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(image_path, 'rb') as f:
|
with open(media_path, 'rb') as f:
|
||||||
image_data = f.read()
|
media_data = f.read()
|
||||||
base64_data = base64.b64encode(image_data).decode('utf-8')
|
base64_data = base64.b64encode(media_data).decode('utf-8')
|
||||||
return f"data:{mime_type};base64,{base64_data}"
|
return (f"data:{mime_type};base64,{base64_data}", media_type)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to read image {image_path}: {e}")
|
print(f"Failed to read media {media_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy alias for backward compatibility
|
||||||
|
def get_image_as_base64(image_path: Path) -> Optional[str]:
|
||||||
|
"""Read an image file and return it as a base64 data URL."""
|
||||||
|
result = get_media_as_base64(image_path)
|
||||||
|
if result and result[1] == 'image':
|
||||||
|
return result[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,22 +80,22 @@ def strip_frontmatter(content: str) -> str:
|
||||||
return '\n'.join(lines[end_idx + 1:]).strip()
|
return '\n'.join(lines[end_idx + 1:]).strip()
|
||||||
|
|
||||||
|
|
||||||
def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
|
def find_media_in_attachments(media_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
|
||||||
"""
|
"""
|
||||||
Search for an image file in common attachment locations.
|
Search for a media file in common attachment locations.
|
||||||
Returns the resolved path if found, None otherwise.
|
Returns the resolved path if found, None otherwise.
|
||||||
"""
|
"""
|
||||||
# Common locations to search for images (fast path)
|
# Common locations to search for media (fast path)
|
||||||
search_paths = [
|
search_paths = [
|
||||||
note_folder / image_name, # Same folder as note
|
note_folder / media_name, # Same folder as note
|
||||||
note_folder / '_attachments' / image_name, # Note's _attachments folder
|
note_folder / '_attachments' / media_name, # Note's _attachments folder
|
||||||
notes_dir / '_attachments' / image_name, # Root _attachments folder
|
notes_dir / '_attachments' / media_name, # Root _attachments folder
|
||||||
]
|
]
|
||||||
|
|
||||||
# Also search in parent folders' _attachments (for nested notes)
|
# Also search in parent folders' _attachments (for nested notes)
|
||||||
current = note_folder
|
current = note_folder
|
||||||
while current != notes_dir and current.parent != current:
|
while current != notes_dir and current.parent != current:
|
||||||
search_paths.append(current / '_attachments' / image_name)
|
search_paths.append(current / '_attachments' / media_name)
|
||||||
current = current.parent
|
current = current.parent
|
||||||
|
|
||||||
for path in search_paths:
|
for path in search_paths:
|
||||||
|
|
@ -86,11 +109,11 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Fallback: search all _attachments folders recursively (slower but thorough)
|
# Fallback: search all _attachments folders recursively (slower but thorough)
|
||||||
# This handles cross-folder image references like in Obsidian
|
# This handles cross-folder media references like in Obsidian
|
||||||
try:
|
try:
|
||||||
for attachment_folder in notes_dir.rglob('_attachments'):
|
for attachment_folder in notes_dir.rglob('_attachments'):
|
||||||
if attachment_folder.is_dir():
|
if attachment_folder.is_dir():
|
||||||
candidate = attachment_folder / image_name
|
candidate = attachment_folder / media_name
|
||||||
if candidate.exists() and candidate.is_file():
|
if candidate.exists() and candidate.is_file():
|
||||||
try:
|
try:
|
||||||
candidate.resolve().relative_to(notes_dir.resolve())
|
candidate.resolve().relative_to(notes_dir.resolve())
|
||||||
|
|
@ -103,63 +126,125 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
|
# Legacy alias
|
||||||
|
def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
|
||||||
|
return find_media_in_attachments(image_name, note_folder, notes_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_media_placeholder(media_type: str, alt_text: str) -> str:
|
||||||
|
"""Generate a placeholder for non-embeddable media (audio, video, PDF)."""
|
||||||
|
safe_alt = alt_text.replace('"', '"').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
|
icons = {'audio': '🎵', 'video': '🎬', 'document': '📄'}
|
||||||
|
labels = {'audio': 'Audio file', 'video': 'Video file', 'document': 'PDF document'}
|
||||||
|
icon = icons.get(media_type, '📎')
|
||||||
|
label = labels.get(media_type, 'Media file')
|
||||||
|
|
||||||
|
return f'''<div style="margin:1.5rem 0;padding:1.5rem;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;display:flex;align-items:center;gap:1rem;">
|
||||||
|
<span style="font-size:2rem;">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;color:var(--text-primary,#212529);">{safe_alt}</div>
|
||||||
|
<div style="font-size:0.875rem;color:var(--text-secondary,#6c757d);">{label} — not available in exported view</div>
|
||||||
|
</div>
|
||||||
|
</div>'''
|
||||||
|
|
||||||
|
|
||||||
|
def process_media_for_export(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
|
||||||
"""
|
"""
|
||||||
Find all image references in markdown and embed them as base64.
|
Process all media references in markdown for standalone HTML export.
|
||||||
|
|
||||||
Handles:
|
Handles:
|
||||||
- Standard markdown images: 
|
- Standard markdown images: 
|
||||||
- Wikilink images: ![[image.png]] or ![[image.png|alt text]]
|
- Wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
|
||||||
|
|
||||||
|
Behavior by media type:
|
||||||
|
- Images (jpg, png, gif, webp): Embedded as base64 data URLs
|
||||||
|
- Audio/Video/PDF: Replaced with styled placeholder HTML (not embedded - too large)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# First, handle wikilink images: ![[image.png]] or ![[image.png|alt text]]
|
# First, handle wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
|
||||||
wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
|
wikilink_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
|
||||||
|
|
||||||
def replace_wikilink_image(match):
|
def replace_wikilink_media(match):
|
||||||
image_name = match.group(1).strip()
|
media_name = match.group(1).strip()
|
||||||
alt_text = match.group(2).strip() if match.group(2) else image_name.split('/')[-1].rsplit('.', 1)[0]
|
alt_text = match.group(2).strip() if match.group(2) else media_name.split('/')[-1].rsplit('.', 1)[0]
|
||||||
|
|
||||||
# Find the image
|
# Check media type first
|
||||||
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
|
media_type = get_media_type(media_name)
|
||||||
|
|
||||||
|
# For non-image media (audio, video, PDF), show placeholder without embedding
|
||||||
|
if media_type in ('audio', 'video', 'document'):
|
||||||
|
return generate_media_placeholder(media_type, alt_text)
|
||||||
|
|
||||||
|
# For images, embed as base64
|
||||||
|
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
|
||||||
|
|
||||||
if resolved_path:
|
if resolved_path:
|
||||||
base64_url = get_image_as_base64(resolved_path)
|
base64_url = get_image_as_base64(resolved_path)
|
||||||
if base64_url:
|
if base64_url:
|
||||||
return f''
|
return f''
|
||||||
|
|
||||||
# Image not found, convert to placeholder
|
# Image not found
|
||||||
return f'![{alt_text}]()'
|
return f'<span style="color:var(--text-tertiary,#999);opacity:0.7;" title="Image not found">🖼️ {alt_text}</span>'
|
||||||
|
|
||||||
markdown_content = re.sub(wikilink_img_pattern, replace_wikilink_image, markdown_content)
|
markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content)
|
||||||
|
|
||||||
# Then, handle standard markdown images: 
|
# 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
|
# Handle external URLs
|
||||||
if img_path.startswith(('http://', 'https://', 'data:')):
|
if media_path.startswith(('http://', 'https://')):
|
||||||
|
# Check if it's a PDF - generate styled external link
|
||||||
|
media_type = get_media_type(media_path)
|
||||||
|
if media_type == 'document':
|
||||||
|
display_name = alt_text or Path(media_path).stem
|
||||||
|
safe_name = display_name.replace('"', '"').replace('<', '<').replace('>', '>')
|
||||||
|
safe_url = media_path.replace('"', '"')
|
||||||
|
return f'''<a href="{safe_url}" target="_blank" rel="noopener noreferrer" style="display:flex;flex-direction:column;gap:0.25rem;padding:1rem 1.25rem;margin:1rem 0;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;color:var(--text-primary,#212529);text-decoration:none;">
|
||||||
|
<span style="font-weight:600;">📄 {safe_name}</span>
|
||||||
|
<span style="font-size:0.75rem;color:var(--text-secondary,#6c757d);">Opens in new tab</span>
|
||||||
|
</a>'''
|
||||||
|
# Other external media: keep as-is (will show as broken image)
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
# Skip already-embedded base64
|
||||||
|
if media_path.startswith('data:'):
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
|
|
||||||
# Skip empty paths (from failed wikilink conversion)
|
# Skip empty paths (from failed wikilink conversion)
|
||||||
if not img_path:
|
if not media_path:
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
|
|
||||||
# Handle /api/images/ paths (convert to filesystem paths)
|
# Check media type first
|
||||||
if img_path.startswith('/api/images/'):
|
media_type = get_media_type(media_path)
|
||||||
# Strip the /api/images/ prefix to get the relative path within notes_dir
|
display_alt = alt_text or Path(media_path).stem
|
||||||
relative_path = img_path[len('/api/images/'):]
|
|
||||||
|
# For non-image media (audio, video, PDF), show placeholder without embedding
|
||||||
|
if media_type in ('audio', 'video', 'document'):
|
||||||
|
return generate_media_placeholder(media_type, display_alt)
|
||||||
|
|
||||||
|
# For images, proceed with base64 embedding
|
||||||
|
# Handle /api/media/ or legacy /api/images/ paths (convert to filesystem paths)
|
||||||
|
if media_path.startswith('/api/media/'):
|
||||||
|
relative_path = media_path[len('/api/media/'):]
|
||||||
|
resolved_path = (notes_dir / relative_path).resolve()
|
||||||
|
elif media_path.startswith('/api/images/'):
|
||||||
|
# Legacy path support for backward compatibility
|
||||||
|
relative_path = media_path[len('/api/images/'):]
|
||||||
resolved_path = (notes_dir / relative_path).resolve()
|
resolved_path = (notes_dir / relative_path).resolve()
|
||||||
else:
|
else:
|
||||||
# Try to resolve the image path relative to note folder
|
# Try to resolve the media path relative to note folder
|
||||||
resolved_path = (note_folder / img_path).resolve()
|
resolved_path = (note_folder / media_path).resolve()
|
||||||
|
|
||||||
# If not found, try the attachment search
|
# If not found, try the attachment search
|
||||||
if not resolved_path.exists():
|
if not resolved_path.exists():
|
||||||
# Extract just the filename and search
|
# Extract just the filename and search
|
||||||
image_name = Path(img_path).name
|
media_name = Path(media_path).name
|
||||||
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
|
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
|
||||||
if not resolved_path:
|
if not resolved_path:
|
||||||
return match.group(0) # Keep original if not found
|
return match.group(0) # Keep original if not found
|
||||||
|
|
||||||
|
|
@ -170,19 +255,26 @@ def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir:
|
||||||
# Path is outside notes_dir, skip
|
# Path is outside notes_dir, skip
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
|
|
||||||
# Get base64 data
|
# Get base64 data for image
|
||||||
base64_url = get_image_as_base64(resolved_path)
|
base64_url = get_image_as_base64(resolved_path)
|
||||||
if base64_url:
|
if base64_url:
|
||||||
return f''
|
return f''
|
||||||
|
|
||||||
# Image not found, keep original
|
# Image not found, keep original
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
|
|
||||||
markdown_content = re.sub(img_pattern, replace_image, markdown_content)
|
markdown_content = re.sub(img_pattern, replace_media, markdown_content)
|
||||||
|
|
||||||
return markdown_content
|
return markdown_content
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy alias for backward compatibility
|
||||||
|
# Legacy alias for backward compatibility
|
||||||
|
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
|
||||||
|
"""Alias for process_media_for_export (legacy name kept for compatibility)."""
|
||||||
|
return process_media_for_export(markdown_content, note_folder, notes_dir)
|
||||||
|
|
||||||
|
|
||||||
def convert_wikilinks_to_html(markdown_content: str) -> str:
|
def convert_wikilinks_to_html(markdown_content: str) -> str:
|
||||||
"""
|
"""
|
||||||
Convert wikilinks [[note]] or [[note|display text]] to HTML links.
|
Convert wikilinks [[note]] or [[note|display text]] to HTML links.
|
||||||
|
|
|
||||||
134
backend/main.py
134
backend/main.py
|
|
@ -476,90 +476,162 @@ async def create_new_folder(request: Request, data: dict):
|
||||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder"))
|
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder"))
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/images/{image_path:path}", tags=["Images"])
|
@api_router.get("/media/{media_path:path}", tags=["Media"])
|
||||||
async def get_image(image_path: str):
|
async def get_media(media_path: str):
|
||||||
"""
|
"""
|
||||||
Serve an image file with authentication protection.
|
Serve a media file (image, audio, video, PDF) with authentication protection.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from backend.utils import ALL_MEDIA_EXTENSIONS
|
||||||
|
|
||||||
notes_dir = config['storage']['notes_dir']
|
notes_dir = config['storage']['notes_dir']
|
||||||
full_path = Path(notes_dir) / image_path
|
full_path = Path(notes_dir) / media_path
|
||||||
|
|
||||||
# Security: Validate path is within notes directory
|
# Security: Validate path is within notes directory
|
||||||
if not validate_path_security(notes_dir, full_path):
|
if not validate_path_security(notes_dir, full_path):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
# Check file exists and is an image
|
# Check file exists
|
||||||
if not full_path.exists() or not full_path.is_file():
|
if not full_path.exists() or not full_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Image not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Validate it's an image file
|
# Validate it's an allowed media file
|
||||||
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
|
||||||
if full_path.suffix.lower() not in allowed_extensions:
|
raise HTTPException(status_code=400, detail="Not an allowed media file")
|
||||||
raise HTTPException(status_code=400, detail="Not an image file")
|
|
||||||
|
|
||||||
# Return the file
|
# Return the file
|
||||||
return FileResponse(full_path)
|
return FileResponse(full_path)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load image"))
|
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file"))
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/upload-image", tags=["Images"])
|
@api_router.post("/upload-media", tags=["Media"])
|
||||||
@limiter.limit("20/minute")
|
@limiter.limit("20/minute")
|
||||||
async def upload_image(request: Request, file: UploadFile = File(...), note_path: str = Form(...)):
|
async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)):
|
||||||
"""
|
"""
|
||||||
Upload an image file and save it to the attachments directory.
|
Upload a media file (image, audio, video, PDF) and save it to the attachments directory.
|
||||||
Returns the relative path to the image for markdown linking.
|
Returns the relative path for markdown linking.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Validate file type
|
from backend.utils import ALL_MEDIA_EXTENSIONS, get_media_type
|
||||||
allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
|
|
||||||
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
# Allowed MIME types for each category
|
||||||
|
allowed_types = {
|
||||||
|
# Images
|
||||||
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
|
||||||
|
# Audio
|
||||||
|
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
|
||||||
|
# Video
|
||||||
|
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo',
|
||||||
|
# Documents
|
||||||
|
'application/pdf',
|
||||||
|
}
|
||||||
|
|
||||||
# Get file extension
|
# Get file extension
|
||||||
file_ext = Path(file.filename).suffix.lower() if file.filename else ''
|
file_ext = Path(file.filename).suffix.lower() if file.filename else ''
|
||||||
|
|
||||||
if file.content_type not in allowed_types and file_ext not in allowed_extensions:
|
if file.content_type not in allowed_types and file_ext not in ALL_MEDIA_EXTENSIONS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. Got: {file.content_type}"
|
detail=f"Invalid file type. Allowed: images, audio (mp3), video (mp4), PDF. Got: {file.content_type}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Read file data
|
# Read file data
|
||||||
file_data = await file.read()
|
file_data = await file.read()
|
||||||
|
|
||||||
# Validate file size (10MB max)
|
# Validate file size - different limits for different types
|
||||||
max_size = 10 * 1024 * 1024 # 10MB in bytes
|
media_type = get_media_type(file.filename) if file.filename else None
|
||||||
|
|
||||||
|
# Size limits: images 10MB, audio 50MB, video 100MB, PDF 20MB
|
||||||
|
size_limits = {
|
||||||
|
'image': 10 * 1024 * 1024,
|
||||||
|
'audio': 50 * 1024 * 1024,
|
||||||
|
'video': 100 * 1024 * 1024,
|
||||||
|
'document': 20 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
max_size = size_limits.get(media_type, 10 * 1024 * 1024)
|
||||||
|
|
||||||
if len(file_data) > max_size:
|
if len(file_data) > max_size:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"File too large. Maximum size: 10MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
|
detail=f"File too large. Maximum size for {media_type or 'this type'}: {max_size // (1024*1024)}MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save the image
|
# Save the file (reusing image save function - it works for any file)
|
||||||
image_path = save_uploaded_image(
|
file_path = save_uploaded_image(
|
||||||
config['storage']['notes_dir'],
|
config['storage']['notes_dir'],
|
||||||
note_path,
|
note_path,
|
||||||
file.filename,
|
file.filename,
|
||||||
file_data
|
file_data
|
||||||
)
|
)
|
||||||
|
|
||||||
if not image_path:
|
if not file_path:
|
||||||
raise HTTPException(status_code=500, detail="Failed to save image")
|
raise HTTPException(status_code=500, detail="Failed to save file")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"path": image_path,
|
"path": file_path,
|
||||||
"filename": Path(image_path).name,
|
"filename": Path(file_path).name,
|
||||||
"message": "Image uploaded successfully"
|
"type": media_type,
|
||||||
|
"message": f"{media_type.capitalize() if media_type else 'File'} uploaded successfully"
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload image"))
|
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload file"))
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/media/move", tags=["Media"])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def move_media_endpoint(request: Request, data: dict):
|
||||||
|
"""Move a media file to a different folder"""
|
||||||
|
try:
|
||||||
|
from backend.utils import ALL_MEDIA_EXTENSIONS
|
||||||
|
|
||||||
|
old_path = data.get('oldPath', '')
|
||||||
|
new_path = data.get('newPath', '')
|
||||||
|
|
||||||
|
if not old_path or not new_path:
|
||||||
|
raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
|
||||||
|
|
||||||
|
notes_dir = config['storage']['notes_dir']
|
||||||
|
old_full_path = Path(notes_dir) / old_path
|
||||||
|
new_full_path = Path(notes_dir) / new_path
|
||||||
|
|
||||||
|
# Security: Validate paths are within notes directory
|
||||||
|
if not validate_path_security(notes_dir, old_full_path):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid source path")
|
||||||
|
if not validate_path_security(notes_dir, new_full_path):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid destination path")
|
||||||
|
|
||||||
|
# Validate it's a media file
|
||||||
|
if old_full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=400, detail="Not a valid media file")
|
||||||
|
|
||||||
|
# Check source exists
|
||||||
|
if not old_full_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Media file not found: {old_path}")
|
||||||
|
|
||||||
|
# Check target doesn't exist
|
||||||
|
if new_full_path.exists():
|
||||||
|
raise HTTPException(status_code=409, detail=f"A file already exists at: {new_path}")
|
||||||
|
|
||||||
|
# Create parent directory if needed
|
||||||
|
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Move the file
|
||||||
|
import shutil
|
||||||
|
shutil.move(str(old_full_path), str(new_full_path))
|
||||||
|
|
||||||
|
return {"success": True, "message": "Media moved successfully", "newPath": new_path}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move media file"))
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/notes/move", tags=["Notes"])
|
@api_router.post("/notes/move", tags=["Notes"])
|
||||||
|
|
|
||||||
|
|
@ -486,38 +486,63 @@ def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Media file type definitions
|
||||||
|
MEDIA_EXTENSIONS = {
|
||||||
|
'image': {'.jpg', '.jpeg', '.png', '.gif', '.webp'},
|
||||||
|
'audio': {'.mp3', '.wav', '.ogg', '.m4a'},
|
||||||
|
'video': {'.mp4', '.webm', '.mov', '.avi'},
|
||||||
|
'document': {'.pdf'},
|
||||||
|
}
|
||||||
|
|
||||||
|
# All supported media extensions (flat set for quick lookup)
|
||||||
|
ALL_MEDIA_EXTENSIONS = set().union(*MEDIA_EXTENSIONS.values())
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_type(filename: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Determine the media type based on file extension.
|
||||||
|
Returns: 'image', 'audio', 'video', 'document', or None if not a media file.
|
||||||
|
"""
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
for media_type, extensions in MEDIA_EXTENSIONS.items():
|
||||||
|
if ext in extensions:
|
||||||
|
return media_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_all_images(notes_dir: str) -> List[Dict]:
|
def get_all_images(notes_dir: str) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Get all images from attachments directories.
|
Get all media files (images, audio, video, documents) from anywhere in the notes directory.
|
||||||
Returns list of image dictionaries with metadata.
|
Returns list of media dictionaries with metadata.
|
||||||
|
Note: Function name kept as 'get_all_images' for backward compatibility.
|
||||||
"""
|
"""
|
||||||
images = []
|
media_files = []
|
||||||
notes_path = Path(notes_dir)
|
notes_path = Path(notes_dir)
|
||||||
|
|
||||||
# Common image extensions
|
# Find all media files recursively in the entire notes directory
|
||||||
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
for media_file in notes_path.rglob("*"):
|
||||||
|
# Skip directories and hidden files/folders
|
||||||
# Find all attachments directories
|
if media_file.is_dir():
|
||||||
for attachments_dir in notes_path.rglob("_attachments"):
|
continue
|
||||||
if not attachments_dir.is_dir():
|
if any(part.startswith('.') for part in media_file.parts):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Find all images in this attachments directory
|
# Check if it's a media file
|
||||||
for image_file in attachments_dir.iterdir():
|
media_type = get_media_type(media_file.name)
|
||||||
if image_file.is_file() and image_file.suffix.lower() in image_extensions:
|
if media_type:
|
||||||
relative_path = image_file.relative_to(notes_path)
|
relative_path = media_file.relative_to(notes_path)
|
||||||
stat = image_file.stat()
|
stat = media_file.stat()
|
||||||
|
|
||||||
images.append({
|
media_files.append({
|
||||||
"name": image_file.name,
|
"name": media_file.name,
|
||||||
"path": str(relative_path.as_posix()),
|
"path": str(relative_path.as_posix()),
|
||||||
"folder": str(relative_path.parent.as_posix()),
|
"folder": str(relative_path.parent.as_posix()) if relative_path.parent != Path('.') else "",
|
||||||
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"type": "image"
|
"type": media_type # 'image', 'audio', 'video', or 'document'
|
||||||
})
|
})
|
||||||
|
|
||||||
return images
|
return media_files
|
||||||
|
|
||||||
|
|
||||||
def parse_tags(content: str) -> List[str]:
|
def parse_tags(content: str) -> List[str]:
|
||||||
|
|
|
||||||
|
|
@ -81,66 +81,93 @@ Content-Type: application/json
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🖼️ Images
|
## 🎬 Media
|
||||||
|
|
||||||
### Get Image
|
### Get Media
|
||||||
```http
|
```http
|
||||||
GET /api/images/{image_path}
|
GET /api/media/{media_path}
|
||||||
```
|
```
|
||||||
Retrieve an image file with authentication protection.
|
Retrieve a media file (image, audio, video, PDF) with authentication protection.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/api/images/folder/_attachments/image-20240417093343.png
|
curl http://localhost:8000/api/media/folder/_attachments/image-20240417093343.png
|
||||||
```
|
```
|
||||||
|
|
||||||
**Security Note:** This endpoint requires authentication and validates that:
|
**Security Note:** This endpoint requires authentication and validates that:
|
||||||
- The image path is within the notes directory (prevents directory traversal)
|
- The media path is within the notes directory (prevents directory traversal)
|
||||||
- The file exists and is a valid image format
|
- The file exists and is a valid media format
|
||||||
- The requesting user is authenticated (if auth is enabled)
|
- The requesting user is authenticated (if auth is enabled)
|
||||||
|
|
||||||
### Upload Image
|
### Upload Media
|
||||||
```http
|
```http
|
||||||
POST /api/upload-image
|
POST /api/upload-media
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
file: <image file>
|
file: <media file>
|
||||||
note_path: <path of note to attach to>
|
note_path: <path of note to attach to>
|
||||||
```
|
```
|
||||||
|
|
||||||
Upload an image file to the `_attachments` directory. Images are automatically organized per-folder and named with timestamps to prevent conflicts.
|
Upload a media file to the `_attachments` directory. Files are automatically organized per-folder and named with timestamps to prevent conflicts.
|
||||||
|
|
||||||
**Supported formats:** JPG, JPEG, PNG, GIF, WEBP
|
**Supported formats & size limits:**
|
||||||
**Maximum size:** 10MB
|
| Type | Formats | Max Size |
|
||||||
|
|------|---------|----------|
|
||||||
|
| Images | JPG, PNG, GIF, WebP | 10 MB |
|
||||||
|
| Audio | MP3, WAV, OGG, M4A | 50 MB |
|
||||||
|
| Video | MP4, WebM, MOV, AVI | 100 MB |
|
||||||
|
| Documents | PDF | 20 MB |
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"path": "folder/_attachments/image-20240417093343.png",
|
"path": "folder/_attachments/media-20240417093343.png",
|
||||||
"filename": "image-20240417093343.png",
|
"filename": "media-20240417093343.png",
|
||||||
"message": "Image uploaded successfully"
|
"message": "Media uploaded successfully"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example (using curl):**
|
**Example (using curl):**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/api/upload-image \
|
curl -X POST http://localhost:8000/api/upload-media \
|
||||||
-F "file=@/path/to/image.png" \
|
-F "file=@/path/to/file.mp3" \
|
||||||
-F "note_path=folder/mynote.md"
|
-F "note_path=folder/mynote.md"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows PowerShell:**
|
**Windows PowerShell:**
|
||||||
```powershell
|
```powershell
|
||||||
curl.exe -X POST http://localhost:8000/api/upload-image -F "file=@C:\path\to\image.png" -F "note_path=folder/mynote.md"
|
curl.exe -X POST http://localhost:8000/api/upload-media -F "file=@C:\path\to\video.mp4" -F "note_path=folder/mynote.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move Media
|
||||||
|
```http
|
||||||
|
POST /api/media/move
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"oldPath": "_attachments/image.png",
|
||||||
|
"newPath": "folder/_attachments/image.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Move a media file to a different location. Supports drag & drop in the UI.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Media moved successfully",
|
||||||
|
"newPath": "folder/_attachments/image.png"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
- Images are stored in `_attachments` folders relative to the note's location
|
- Media is stored in `_attachments` folders relative to the note's location
|
||||||
- Filenames are automatically timestamped (e.g., `image-20240417093343.png`)
|
- Filenames are automatically timestamped (e.g., `media-20240417093343.mp3`)
|
||||||
- Images appear in the sidebar navigation and can be viewed/deleted directly
|
- Media appears in the sidebar navigation and can be viewed/deleted directly
|
||||||
- Drag & drop images into the editor automatically uploads and inserts markdown
|
- Drag & drop files into the editor automatically uploads and inserts markdown
|
||||||
- All image access requires authentication when security is enabled
|
- All media access requires authentication when security is enabled
|
||||||
|
|
||||||
## 📁 Folders
|
## 📁 Folders
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,15 @@
|
||||||
- **HTML Export** - Export notes as standalone HTML files with embedded images
|
- **HTML Export** - Export notes as standalone HTML files with embedded images
|
||||||
- **Public Sharing** - Share notes via token-based URLs without requiring authentication (see [SHARING.md](SHARING.md))
|
- **Public Sharing** - Share notes via token-based URLs without requiring authentication (see [SHARING.md](SHARING.md))
|
||||||
|
|
||||||
### Image Support
|
### Media Support
|
||||||
- **Drag & drop upload** - Drop images from your file system directly into the editor
|
- **Drag & drop upload** - Drop files from your file system directly into the editor
|
||||||
- **Clipboard paste** - Paste images from clipboard with Ctrl+V
|
- **Clipboard paste** - Paste images from clipboard with Ctrl+V
|
||||||
- **Multiple formats** - Supports JPG, PNG, GIF, and WebP (max 10MB)
|
- **Images** - JPG, PNG, GIF, WebP (max 10MB)
|
||||||
|
- **Audio** - MP3, WAV, OGG, M4A (max 50MB)
|
||||||
|
- **Video** - MP4, WebM, MOV, AVI (max 100MB)
|
||||||
|
- **Documents** - PDF (max 20MB)
|
||||||
|
- **In-app viewing** - View all media types directly in the sidebar
|
||||||
|
- **Inline preview** - Audio/video players and PDF viewer embedded in notes
|
||||||
|
|
||||||
### Organization
|
### Organization
|
||||||
- **Folder hierarchy** - Organize notes in nested folders
|
- **Folder hierarchy** - Organize notes in nested folders
|
||||||
|
|
|
||||||
732
frontend/app.js
732
frontend/app.js
File diff suppressed because it is too large
Load Diff
|
|
@ -444,6 +444,92 @@
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Embedded Media (audio, video, PDF) */
|
||||||
|
.markdown-preview .media-embed {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio embeds */
|
||||||
|
.markdown-preview .media-audio {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
.markdown-preview .media-audio audio {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
border-radius: 2rem;
|
||||||
|
}
|
||||||
|
.markdown-preview .media-audio .media-caption {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video embeds */
|
||||||
|
.markdown-preview .media-video {
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.markdown-preview .media-video video {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 450px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PDF embeds (iframe) */
|
||||||
|
.markdown-preview .media-pdf {
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.markdown-preview .media-pdf iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 600px;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PDF links (for external PDFs) */
|
||||||
|
.markdown-preview .pdf-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.markdown-preview .pdf-link:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.markdown-preview .pdf-link:hover .pdf-link-content {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
.markdown-preview .pdf-link-content {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.markdown-preview .pdf-link-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Task lists */
|
/* Task lists */
|
||||||
.markdown-preview input[type="checkbox"] {
|
.markdown-preview input[type="checkbox"] {
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
|
@ -849,6 +935,12 @@
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zen mode mobile adjustments - use 95% width instead of fixed padding */
|
||||||
|
.zen-mode-active .zen-editor-container {
|
||||||
|
padding: 1rem 2.5%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
@media (min-width: 769px) {
|
||||||
|
|
@ -1377,7 +1469,7 @@
|
||||||
<svg class="w-3.5 h-3.5 flex-shrink-0" style="color: var(--warning, #eab308);" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5 flex-shrink-0" style="color: var(--warning, #eab308);" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs truncate" x-text="fav.name"></span>
|
<span class="text-xs truncate" x-text="fav.name" :title="fav.name"></span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click.stop="toggleFavorite(fav.path)"
|
@click.stop="toggleFavorite(fav.path)"
|
||||||
|
|
@ -1417,24 +1509,24 @@
|
||||||
<div
|
<div
|
||||||
class="text-xs px-2 py-1 mb-2 rounded transition-all font-medium text-center"
|
class="text-xs px-2 py-1 mb-2 rounded transition-all font-medium text-center"
|
||||||
:class="{
|
:class="{
|
||||||
'border-2 border-dashed bg-accent-light': (draggedNote || draggedFolder) && dragOverFolder === '',
|
'border-2 border-dashed bg-accent-light': draggedItem && dragOverFolder === '',
|
||||||
'border-2 border-dashed': (draggedNote || draggedFolder) && dragOverFolder !== '',
|
'border-2 border-dashed': draggedItem && dragOverFolder !== '',
|
||||||
'border-2 border-transparent': !draggedNote && !draggedFolder
|
'border-2 border-transparent': !draggedItem
|
||||||
}"
|
}"
|
||||||
:style="{
|
:style="{
|
||||||
'color': (draggedNote || draggedFolder) ? (dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--text-secondary)') : 'var(--text-tertiary)',
|
'color': draggedItem ? (dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--text-secondary)') : 'var(--text-tertiary)',
|
||||||
'opacity': (draggedNote || draggedFolder) ? '1' : '0.8',
|
'opacity': draggedItem ? '1' : '0.8',
|
||||||
'border-color': (draggedNote || draggedFolder) && dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--border-secondary)',
|
'border-color': draggedItem && dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--border-secondary)',
|
||||||
'background-color': dragOverFolder === '' ? 'var(--accent-light)' : ''
|
'background-color': dragOverFolder === '' ? 'var(--accent-light)' : ''
|
||||||
}"
|
}"
|
||||||
@dragover.prevent="if(draggedNote || draggedFolder) dragOverFolder = '';"
|
@dragover.prevent="if(draggedItem) dragOverFolder = '';"
|
||||||
@dragenter.prevent="if(draggedNote || draggedFolder) dragOverFolder = '';"
|
@dragenter.prevent="if(draggedItem) dragOverFolder = '';"
|
||||||
@dragleave="if(draggedNote || draggedFolder) dragOverFolder = null;"
|
@dragleave="if(draggedItem) dragOverFolder = null;"
|
||||||
@drop="if(draggedNote || draggedFolder) onFolderDrop('')"
|
@drop="if(draggedItem) onFolderDrop('')"
|
||||||
style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;"
|
style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;"
|
||||||
>
|
>
|
||||||
<span x-show="!draggedNote && !draggedFolder && !draggedItem" x-text="t('sidebar.drag_hint')"></span>
|
<span x-show="!draggedItem" x-text="t('sidebar.drag_hint')"></span>
|
||||||
<span x-show="draggedNote || draggedFolder" x-text="t('sidebar.root_folder')"></span>
|
<span x-show="draggedItem" x-text="t('sidebar.root_folder')"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1443,34 +1535,8 @@
|
||||||
<div x-html="renderFolderRecursive(folder, 0, true)"></div>
|
<div x-html="renderFolderRecursive(folder, 0, true)"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Root notes and images (no folder) - shown after folders -->
|
<!-- Root notes and images (no folder) - rendered via JS for consistency -->
|
||||||
<template x-for="note in (folderTree.__root__ && folderTree.__root__.notes ? folderTree.__root__.notes : [])" :key="note.path">
|
<div x-html="renderRootItems()"></div>
|
||||||
<div
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="onNoteDragStart(note.path, $event)"
|
|
||||||
@dragend="onNoteDragEnd()"
|
|
||||||
@click="openItem(note.path, note.type)"
|
|
||||||
class="note-item px-2 py-1 text-sm relative"
|
|
||||||
:style="((note.type === 'image' ? currentImage === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type === 'image' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')"
|
|
||||||
@mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
|
|
||||||
@mouseout="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='transparent'"
|
|
||||||
>
|
|
||||||
<span class="truncate" style="display: block; padding-right: 30px;">
|
|
||||||
<template x-if="note.type === 'image'">🖼️ </template>
|
|
||||||
<span x-text="note.name"></span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
@click.stop="note.type === 'image' ? deleteImage(note.path) : deleteNote(note.path, note.name)"
|
|
||||||
class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity"
|
|
||||||
style="opacity: 0; color: var(--error);"
|
|
||||||
:title="note.type === 'image' ? t('toolbar.delete_image') : t('toolbar.delete_note')"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<template x-if="notes.length === 0 && allFolders.length === 0">
|
<template x-if="notes.length === 0 && allFolders.length === 0">
|
||||||
|
|
@ -1550,8 +1616,8 @@
|
||||||
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
|
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
|
||||||
onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'"
|
onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'"
|
||||||
>
|
>
|
||||||
<div class="font-medium truncate" x-text="note.name"></div>
|
<div class="font-medium truncate" x-text="note.name" :title="note.name"></div>
|
||||||
<div class="text-xs truncate" style="color: var(--text-tertiary);" x-html="(note.matches && note.matches.length > 0) ? note.matches[0].context : (note.folder || 'Root')"></div>
|
<div class="text-xs truncate" style="color: var(--text-tertiary);" x-html="(note.matches && note.matches.length > 0) ? note.matches[0].context : (note.folder || 'Root')" :title="note.folder || 'Root'"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1646,7 +1712,7 @@
|
||||||
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
|
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
|
||||||
onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'"
|
onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'"
|
||||||
>
|
>
|
||||||
<span class="truncate block" x-text="note.name"></span>
|
<span class="truncate block" x-text="note.name" :title="note.name"></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div x-show="searchResults.length === 0" class="px-3 py-4 text-center text-xs" style="color: var(--text-tertiary);" x-text="t('tags.no_matches')">
|
<div x-show="searchResults.length === 0" class="px-3 py-4 text-center text-xs" style="color: var(--text-tertiary);" x-text="t('tags.no_matches')">
|
||||||
|
|
@ -1684,6 +1750,7 @@
|
||||||
class="block truncate"
|
class="block truncate"
|
||||||
:style="heading.level === 1 ? 'font-weight: 600;' : (heading.level === 2 ? 'font-weight: 500;' : '')"
|
:style="heading.level === 1 ? 'font-weight: 600;' : (heading.level === 2 ? 'font-weight: 500;' : '')"
|
||||||
x-text="heading.text"
|
x-text="heading.text"
|
||||||
|
:title="heading.text"
|
||||||
></span>
|
></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -1868,7 +1935,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="!currentNote && !currentImage">
|
<template x-if="!currentNote && !currentMedia">
|
||||||
<!-- Notes Homepage -->
|
<!-- Notes Homepage -->
|
||||||
<div class="flex-1 overflow-y-auto custom-scrollbar" style="background-color: var(--bg-primary);">
|
<div class="flex-1 overflow-y-auto custom-scrollbar" style="background-color: var(--bg-primary);">
|
||||||
<!-- Mobile Menu Button (Homepage) -->
|
<!-- Mobile Menu Button (Homepage) -->
|
||||||
|
|
@ -2044,12 +2111,12 @@
|
||||||
>
|
>
|
||||||
<!-- Delete Button -->
|
<!-- Delete Button -->
|
||||||
<button
|
<button
|
||||||
@click.stop="note.type === 'image' ? deleteImage(note.path) : deleteNote(note.path, note.name)"
|
@click.stop="note.type !== 'note' ? deleteMedia(note.path) : deleteNote(note.path, note.name)"
|
||||||
class="card-delete-btn hidden sm:block"
|
class="card-delete-btn hidden sm:block"
|
||||||
style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);"
|
style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);"
|
||||||
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
||||||
onmouseout="this.style.backgroundColor='var(--bg-secondary)'"
|
onmouseout="this.style.backgroundColor='var(--bg-secondary)'"
|
||||||
:title="note.type === 'image' ? t('toolbar.delete_image') : t('toolbar.delete_note')"
|
:title="note.type !== 'note' ? t('toolbar.delete_image') : t('toolbar.delete_note')"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
|
@ -2080,7 +2147,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if="currentNote || currentImage">
|
<template x-if="currentNote || currentMedia">
|
||||||
<!-- Editor Area -->
|
<!-- Editor Area -->
|
||||||
<div class="flex-1 flex flex-col overflow-hidden" :class="{'zen-editor-container': zenMode}" style="background-color: var(--bg-primary);">
|
<div class="flex-1 flex flex-col overflow-hidden" :class="{'zen-editor-container': zenMode}" style="background-color: var(--bg-primary);">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
|
|
@ -2101,7 +2168,7 @@
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
:value="currentNote ? currentNoteName : (currentImage ? currentImage.split('/').pop() : '')"
|
:value="currentNote ? currentNoteName : (currentMedia ? currentMedia.split('/').pop() : '')"
|
||||||
@input="if (currentNote) currentNoteName = $event.target.value"
|
@input="if (currentNote) currentNoteName = $event.target.value"
|
||||||
@blur="if (currentNote) renameNote()"
|
@blur="if (currentNote) renameNote()"
|
||||||
:readonly="!currentNote"
|
:readonly="!currentNote"
|
||||||
|
|
@ -2140,12 +2207,12 @@
|
||||||
|
|
||||||
<!-- Delete Button -->
|
<!-- Delete Button -->
|
||||||
<button
|
<button
|
||||||
@click="currentImage ? deleteImage(currentImage) : deleteCurrentNote()"
|
@click="currentMedia ? deleteMedia(currentMedia) : deleteCurrentNote()"
|
||||||
class="p-2 rounded-lg"
|
class="p-2 rounded-lg"
|
||||||
style="color: var(--error);"
|
style="color: var(--error);"
|
||||||
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
||||||
onmouseout="this.style.backgroundColor='transparent'"
|
onmouseout="this.style.backgroundColor='transparent'"
|
||||||
:title="currentImage ? t('toolbar.delete_image') : t('toolbar.delete_note')"
|
:title="currentMedia ? t('toolbar.delete_image') : t('toolbar.delete_note')"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
|
@ -2287,7 +2354,7 @@
|
||||||
<div class="flex-1 flex relative" style="min-height: 0;">
|
<div class="flex-1 flex relative" style="min-height: 0;">
|
||||||
<!-- Editor -->
|
<!-- Editor -->
|
||||||
<div
|
<div
|
||||||
x-show="!currentImage && (viewMode === 'edit' || viewMode === 'split')"
|
x-show="!currentMedia && (viewMode === 'edit' || viewMode === 'split')"
|
||||||
class="flex flex-col"
|
class="flex flex-col"
|
||||||
style="min-height: 0;"
|
style="min-height: 0;"
|
||||||
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
|
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
|
||||||
|
|
@ -2366,7 +2433,7 @@
|
||||||
|
|
||||||
<!-- Split view resize handle -->
|
<!-- Split view resize handle -->
|
||||||
<div
|
<div
|
||||||
x-show="!currentImage && viewMode === 'split'"
|
x-show="!currentMedia && viewMode === 'split'"
|
||||||
@mousedown="startSplitResize($event)"
|
@mousedown="startSplitResize($event)"
|
||||||
class="split-resize-handle"
|
class="split-resize-handle"
|
||||||
style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;"
|
style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;"
|
||||||
|
|
@ -2376,7 +2443,7 @@
|
||||||
|
|
||||||
<!-- Preview -->
|
<!-- Preview -->
|
||||||
<div
|
<div
|
||||||
x-show="(viewMode === 'preview' || viewMode === 'split') && !currentImage"
|
x-show="(viewMode === 'preview' || viewMode === 'split') && !currentMedia"
|
||||||
class="overflow-y-auto overflow-x-hidden custom-scrollbar"
|
class="overflow-y-auto overflow-x-hidden custom-scrollbar"
|
||||||
style="background-color: var(--bg-primary); min-height: 0;"
|
style="background-color: var(--bg-primary); min-height: 0;"
|
||||||
:style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'"
|
:style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'"
|
||||||
|
|
@ -2499,17 +2566,55 @@
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image Viewer -->
|
<!-- Media Viewer (images, audio, video, PDF) -->
|
||||||
<template x-if="currentImage">
|
<template x-if="currentMedia">
|
||||||
<div
|
<div
|
||||||
class="flex-1 flex items-center justify-center overflow-auto"
|
class="flex-1 flex items-center justify-center overflow-auto"
|
||||||
style="background-color: var(--bg-primary); min-height: 0;"
|
style="background-color: var(--bg-primary); min-height: 0;"
|
||||||
>
|
>
|
||||||
|
<!-- Image -->
|
||||||
|
<template x-if="currentMediaType === 'image'">
|
||||||
<img
|
<img
|
||||||
:src="`/api/images/${currentImage}`"
|
:src="`/api/media/${currentMedia}`"
|
||||||
:alt="currentImage.split('/').pop()"
|
:alt="currentMedia.split('/').pop()"
|
||||||
style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;"
|
style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Audio -->
|
||||||
|
<template x-if="currentMediaType === 'audio'">
|
||||||
|
<div class="flex flex-col items-center gap-4 p-8">
|
||||||
|
<div class="text-6xl">🎵</div>
|
||||||
|
<div class="text-lg font-medium" style="color: var(--text-primary);" x-text="currentMedia.split('/').pop()"></div>
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
:src="`/api/media/${currentMedia}`"
|
||||||
|
style="width: 100%; max-width: 500px;"
|
||||||
|
>
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Video -->
|
||||||
|
<template x-if="currentMediaType === 'video'">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
:src="`/api/media/${currentMedia}`"
|
||||||
|
style="max-width: 100%; max-height: 100%; padding: 1rem;"
|
||||||
|
>
|
||||||
|
Your browser does not support the video element.
|
||||||
|
</video>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- PDF -->
|
||||||
|
<template x-if="currentMediaType === 'document'">
|
||||||
|
<iframe
|
||||||
|
:src="`/api/media/${currentMedia}`"
|
||||||
|
style="width: 100%; height: 100%; border: none;"
|
||||||
|
:title="currentMedia.split('/').pop()"
|
||||||
|
></iframe>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2836,8 +2941,8 @@
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium truncate" style="color: var(--text-primary);" x-text="note.name"></div>
|
<div class="font-medium truncate" style="color: var(--text-primary);" x-text="note.name" :title="note.name"></div>
|
||||||
<div class="text-xs truncate" style="color: var(--text-secondary);" x-show="note.folder" x-text="note.folder"></div>
|
<div class="text-xs truncate" style="color: var(--text-secondary);" x-show="note.folder" x-text="note.folder" :title="note.folder"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -2958,18 +3063,18 @@
|
||||||
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
|
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
|
||||||
<button
|
<button
|
||||||
class="mobile-bottom-tab"
|
class="mobile-bottom-tab"
|
||||||
:class="{'active': activePanel === 'files' && (mobileSidebarOpen || (!currentNote && !currentImage && !showGraph))}"
|
:class="{'active': activePanel === 'files' && (mobileSidebarOpen || (!currentNote && !currentMedia && !showGraph))}"
|
||||||
@click="mobileFilesTabClick()"
|
@click="mobileFilesTabClick()"
|
||||||
:aria-label="t('sidebar.files')"
|
:aria-label="t('sidebar.files')"
|
||||||
>
|
>
|
||||||
<!-- Show home icon when viewing content, folder icon when on homepage -->
|
<!-- Show home icon when viewing content, folder icon when on homepage -->
|
||||||
<svg x-show="currentNote || currentImage || showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg x-show="currentNote || currentMedia || showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<svg x-show="!currentNote && !currentImage && !showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg x-show="!currentNote && !currentMedia && !showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span x-text="(currentNote || currentImage || showGraph) ? t('homepage.title') : t('sidebar.files')"></span>
|
<span x-text="(currentNote || currentMedia || showGraph) ? t('homepage.title') : t('sidebar.files')"></span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="mobile-bottom-tab relative"
|
class="mobile-bottom-tab relative"
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"no_notes": "Du hast noch keine Notizen. Erstelle deine erste!",
|
"no_notes": "Du hast noch keine Notizen. Erstelle deine erste!",
|
||||||
"no_notes_yet": "Noch keine Notizen oder Ordner.",
|
"no_notes_yet": "Noch keine Notizen oder Ordner.",
|
||||||
"create_first": "Erstelle deine erste Notiz oder Ordner!",
|
"create_first": "Erstelle deine erste Notiz oder Ordner!",
|
||||||
|
"drop_to_root": "Hier ablegen für Stammverzeichnis",
|
||||||
"no_favorites": "Noch keine Favoriten",
|
"no_favorites": "Noch keine Favoriten",
|
||||||
"no_results": "Keine Ergebnisse gefunden",
|
"no_results": "Keine Ergebnisse gefunden",
|
||||||
"expand_all": "Alle Ordner aufklappen",
|
"expand_all": "Alle Ordner aufklappen",
|
||||||
|
|
@ -194,16 +195,17 @@
|
||||||
"error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut."
|
"error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut."
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "Bild \"{{name}}\" löschen?",
|
"confirm_delete": "\"{{name}}\" löschen?",
|
||||||
"upload_failed": "Bild-Upload fehlgeschlagen",
|
"upload_failed": "Datei-Upload fehlgeschlagen",
|
||||||
"no_valid_files": "Keine gültigen Bilddateien gefunden. Unterstützte Formate: JPG, PNG, GIF, WEBP",
|
"no_valid_files": "Keine gültigen Dateien gefunden. Unterstützt: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "Unterstützt JPG, PNG, GIF, WebP (max 10MB)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "Verschieben der Notiz fehlgeschlagen.",
|
"failed_note": "Verschieben der Notiz fehlgeschlagen.",
|
||||||
"failed_folder": "Verschieben des Ordners fehlgeschlagen."
|
"failed_folder": "Verschieben des Ordners fehlgeschlagen.",
|
||||||
|
"failed_media": "Verschieben der Mediendatei fehlgeschlagen."
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -194,16 +194,17 @@
|
||||||
"error_incorrect_password": "Incorrect password. Please try again."
|
"error_incorrect_password": "Incorrect password. Please try again."
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "Delete image \"{{name}}\"?",
|
"confirm_delete": "Delete \"{{name}}\"?",
|
||||||
"upload_failed": "Failed to upload image",
|
"upload_failed": "Failed to upload file",
|
||||||
"no_valid_files": "No valid image files found. Supported formats: JPG, PNG, GIF, WEBP",
|
"no_valid_files": "No valid files found. Supported: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "Supports JPG, PNG, GIF, WebP (max 10MB)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "Failed to move note.",
|
"failed_note": "Failed to move note.",
|
||||||
"failed_folder": "Failed to move folder."
|
"failed_folder": "Failed to move folder.",
|
||||||
|
"failed_media": "Failed to move media file."
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"no_notes": "No notes yet. Create your first note!",
|
"no_notes": "No notes yet. Create your first note!",
|
||||||
"no_notes_yet": "No notes or folders yet.",
|
"no_notes_yet": "No notes or folders yet.",
|
||||||
"create_first": "Create your first note or folder!",
|
"create_first": "Create your first note or folder!",
|
||||||
|
"drop_to_root": "Drop here for root",
|
||||||
"no_favorites": "No favorites yet",
|
"no_favorites": "No favorites yet",
|
||||||
"no_results": "No results found",
|
"no_results": "No results found",
|
||||||
"expand_all": "Expand all folders",
|
"expand_all": "Expand all folders",
|
||||||
|
|
@ -194,16 +195,17 @@
|
||||||
"error_incorrect_password": "Incorrect password. Please try again."
|
"error_incorrect_password": "Incorrect password. Please try again."
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "Delete image \"{{name}}\"?",
|
"confirm_delete": "Delete \"{{name}}\"?",
|
||||||
"upload_failed": "Failed to upload image",
|
"upload_failed": "Failed to upload file",
|
||||||
"no_valid_files": "No valid image files found. Supported formats: JPG, PNG, GIF, WEBP",
|
"no_valid_files": "No valid files found. Supported: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "Supports JPG, PNG, GIF, WebP (max 10MB)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "Failed to move note.",
|
"failed_note": "Failed to move note.",
|
||||||
"failed_folder": "Failed to move folder."
|
"failed_folder": "Failed to move folder.",
|
||||||
|
"failed_media": "Failed to move media file."
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"no_notes": "Aún no hay notas. ¡Crea tu primera nota!",
|
"no_notes": "Aún no hay notas. ¡Crea tu primera nota!",
|
||||||
"no_notes_yet": "Aún no hay notas ni carpetas.",
|
"no_notes_yet": "Aún no hay notas ni carpetas.",
|
||||||
"create_first": "¡Crea tu primera nota o carpeta!",
|
"create_first": "¡Crea tu primera nota o carpeta!",
|
||||||
|
"drop_to_root": "Soltar aquí para raíz",
|
||||||
"no_favorites": "Aún no hay favoritos",
|
"no_favorites": "Aún no hay favoritos",
|
||||||
"no_results": "No se encontraron resultados",
|
"no_results": "No se encontraron resultados",
|
||||||
"expand_all": "Expandir todas las carpetas",
|
"expand_all": "Expandir todas las carpetas",
|
||||||
|
|
@ -194,16 +195,17 @@
|
||||||
"error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo."
|
"error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo."
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "¿Eliminar imagen \"{{name}}\"?",
|
"confirm_delete": "¿Eliminar \"{{name}}\"?",
|
||||||
"upload_failed": "Error al subir imagen",
|
"upload_failed": "Error al subir archivo",
|
||||||
"no_valid_files": "No se encontraron archivos de imagen válidos. Formatos soportados: JPG, PNG, GIF, WEBP",
|
"no_valid_files": "No se encontraron archivos válidos. Soporta: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "Soporta JPG, PNG, GIF, WebP (máx 10MB)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "Error al mover la nota.",
|
"failed_note": "Error al mover la nota.",
|
||||||
"failed_folder": "Error al mover la carpeta."
|
"failed_folder": "Error al mover la carpeta.",
|
||||||
|
"failed_media": "Error al mover el archivo multimedia."
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"no_notes": "Pas encore de notes. Créez votre première note !",
|
"no_notes": "Pas encore de notes. Créez votre première note !",
|
||||||
"no_notes_yet": "Pas encore de notes ni de dossiers.",
|
"no_notes_yet": "Pas encore de notes ni de dossiers.",
|
||||||
"create_first": "Créez votre première note ou dossier !",
|
"create_first": "Créez votre première note ou dossier !",
|
||||||
|
"drop_to_root": "Déposer ici pour la racine",
|
||||||
"no_favorites": "Pas encore de favoris",
|
"no_favorites": "Pas encore de favoris",
|
||||||
"no_results": "Aucun résultat trouvé",
|
"no_results": "Aucun résultat trouvé",
|
||||||
"expand_all": "Développer tous les dossiers",
|
"expand_all": "Développer tous les dossiers",
|
||||||
|
|
@ -194,16 +195,17 @@
|
||||||
"error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer."
|
"error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer."
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "Supprimer l'image \"{{name}}\" ?",
|
"confirm_delete": "Supprimer \"{{name}}\" ?",
|
||||||
"upload_failed": "Échec du téléchargement de l'image",
|
"upload_failed": "Échec du téléchargement du fichier",
|
||||||
"no_valid_files": "Aucun fichier image valide trouvé. Formats supportés : JPG, PNG, GIF, WEBP",
|
"no_valid_files": "Aucun fichier valide trouvé. Supporté : JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "Supporte JPG, PNG, GIF, WebP (max 10 Mo)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "Échec du déplacement de la note.",
|
"failed_note": "Échec du déplacement de la note.",
|
||||||
"failed_folder": "Échec du déplacement du dossier."
|
"failed_folder": "Échec du déplacement du dossier.",
|
||||||
|
"failed_media": "Échec du déplacement du fichier multimédia."
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -194,16 +194,17 @@
|
||||||
"error_incorrect_password": "Password errata. Riprova."
|
"error_incorrect_password": "Password errata. Riprova."
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "Eliminare l'immagine \"{{name}}\"?",
|
"confirm_delete": "Eliminare \"{{name}}\"?",
|
||||||
"upload_failed": "Caricamento immagine fallito",
|
"upload_failed": "Caricamento file fallito",
|
||||||
"no_valid_files": "Nessun file immagine valido. Formati supportati: JPG, PNG, GIF, WEBP",
|
"no_valid_files": "Nessun file valido trovato. Supportati: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "Supporta JPG, PNG, GIF, WebP (max 10MB)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "Impossibile spostare la nota.",
|
"failed_note": "Impossibile spostare la nota.",
|
||||||
"failed_folder": "Impossibile spostare la cartella."
|
"failed_folder": "Impossibile spostare la cartella.",
|
||||||
|
"failed_media": "Impossibile spostare il file multimediale."
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -194,16 +194,17 @@
|
||||||
"error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。"
|
"error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。"
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "画像「{{name}}」を削除しますか?",
|
"confirm_delete": "「{{name}}」を削除しますか?",
|
||||||
"upload_failed": "画像のアップロードに失敗しました",
|
"upload_failed": "ファイルのアップロードに失敗しました",
|
||||||
"no_valid_files": "有効な画像ファイルが見つかりません。対応形式: JPG, PNG, GIF, WEBP",
|
"no_valid_files": "有効なファイルが見つかりません。対応: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "JPG, PNG, GIF, WebP対応(最大10MB)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "ノートの移動に失敗しました。",
|
"failed_note": "ノートの移動に失敗しました。",
|
||||||
"failed_folder": "フォルダの移動に失敗しました。"
|
"failed_folder": "フォルダの移動に失敗しました。",
|
||||||
|
"failed_media": "メディアファイルの移動に失敗しました。"
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -194,16 +194,17 @@
|
||||||
"error_incorrect_password": "Неверный пароль. Попробуйте снова."
|
"error_incorrect_password": "Неверный пароль. Попробуйте снова."
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "Удалить изображение «{{name}}»?",
|
"confirm_delete": "Удалить «{{name}}»?",
|
||||||
"upload_failed": "Не удалось загрузить изображение",
|
"upload_failed": "Не удалось загрузить файл",
|
||||||
"no_valid_files": "Не найдено подходящих изображений. Поддерживаемые форматы: JPG, PNG, GIF, WEBP",
|
"no_valid_files": "Не найдено подходящих файлов. Поддерживает: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "Поддерживает JPG, PNG, GIF, WebP (макс. 10МБ)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "Не удалось переместить заметку.",
|
"failed_note": "Не удалось переместить заметку.",
|
||||||
"failed_folder": "Не удалось переместить папку."
|
"failed_folder": "Не удалось переместить папку.",
|
||||||
|
"failed_media": "Не удалось переместить медиафайл."
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -194,16 +194,17 @@
|
||||||
"error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova."
|
"error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova."
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "Izbrišem sliko \"{{name}}\"?",
|
"confirm_delete": "Izbrišem \"{{name}}\"?",
|
||||||
"upload_failed": "Nalaganje slike ni uspelo",
|
"upload_failed": "Nalaganje datoteke ni uspelo",
|
||||||
"no_valid_files": "Ni najdenih veljavnih slikovnih datotek. Podprti formati: JPG, PNG, GIF, WEBP",
|
"no_valid_files": "Ni najdenih veljavnih datotek. Podprto: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "Podpira JPG, PNG, GIF, WebP (največ 10MB)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "Napaka pri premikanju zapisa.",
|
"failed_note": "Napaka pri premikanju zapisa.",
|
||||||
"failed_folder": "Napaka pri premikanju mape."
|
"failed_folder": "Napaka pri premikanju mape.",
|
||||||
|
"failed_media": "Napaka pri premikanju medijske datoteke."
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -194,16 +194,17 @@
|
||||||
"error_incorrect_password": "密码错误。请重试。"
|
"error_incorrect_password": "密码错误。请重试。"
|
||||||
},
|
},
|
||||||
|
|
||||||
"images": {
|
"media": {
|
||||||
"confirm_delete": "删除图片 \"{{name}}\"?",
|
"confirm_delete": "删除 \"{{name}}\"?",
|
||||||
"upload_failed": "上传图片失败",
|
"upload_failed": "上传文件失败",
|
||||||
"no_valid_files": "未找到有效的图片文件。支持的格式:JPG、PNG、GIF、WEBP",
|
"no_valid_files": "未找到有效文件。支持:JPG, PNG, GIF, WebP, MP3, MP4, PDF",
|
||||||
"formats": "支持 JPG、PNG、GIF、WebP(最大 10MB)"
|
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
|
||||||
},
|
},
|
||||||
|
|
||||||
"move": {
|
"move": {
|
||||||
"failed_note": "移动笔记失败。",
|
"failed_note": "移动笔记失败。",
|
||||||
"failed_folder": "移动文件夹失败。"
|
"failed_folder": "移动文件夹失败。",
|
||||||
|
"failed_media": "移动媒体文件失败。"
|
||||||
},
|
},
|
||||||
|
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue