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