Show folder tree for notes.

This commit is contained in:
2025-07-06 20:57:17 +02:00
parent 11b25ca867
commit eca8dca5d2
6 changed files with 1985 additions and 171 deletions

381
app.py
View File

@@ -1,5 +1,5 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file, abort
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, Note, NoteLink, NoteVisibility
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, Note, NoteLink, NoteVisibility, NoteFolder
from data_formatting import (
format_duration, prepare_export_data, prepare_team_hours_export_data,
format_table_data, format_graph_data, format_team_data, format_burndown_data
@@ -1616,6 +1616,7 @@ def notes_list():
# Get filter parameters
visibility_filter = request.args.get('visibility', 'all')
tag_filter = request.args.get('tag')
folder_filter = request.args.get('folder')
search_query = request.args.get('search', request.args.get('q'))
# Base query - all notes in user's company
@@ -1654,6 +1655,10 @@ def notes_list():
if tag_filter:
query = query.filter(Note.tags.like(f'%{tag_filter}%'))
# Apply folder filter
if folder_filter:
query = query.filter_by(folder=folder_filter)
# Apply search
if search_query:
query = query.filter(
@@ -1672,16 +1677,66 @@ def notes_list():
for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all():
all_tags.update(note.get_tags_list())
# Get all unique folders for filter dropdown
all_folders = set()
# Get folders from NoteFolder table
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
for folder in folder_records:
all_folders.add(folder.path)
# Also get folders from notes (for backward compatibility)
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
for note in folder_notes:
if note.folder:
all_folders.add(note.folder)
# Get projects for filter
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
# Build folder tree structure for sidebar
folder_counts = {}
for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all():
if note.folder and note.can_user_view(g.user):
# Add this folder and all parent folders
parts = note.folder.split('/')
for i in range(len(parts)):
folder_path = '/'.join(parts[:i+1])
folder_counts[folder_path] = folder_counts.get(folder_path, 0) + (1 if i == len(parts)-1 else 0)
# Initialize counts for empty folders
for folder_path in all_folders:
if folder_path not in folder_counts:
folder_counts[folder_path] = 0
# Build folder tree structure
folder_tree = {}
for folder in sorted(all_folders):
parts = folder.split('/')
current = folder_tree
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Leaf folder
current[folder] = {}
else:
# Navigate to parent
parent_path = '/'.join(parts[:i+1])
if parent_path not in current:
current[parent_path] = {}
current = current[parent_path]
return render_template('notes_list.html',
title='Notes',
notes=notes,
visibility_filter=visibility_filter,
tag_filter=tag_filter,
folder_filter=folder_filter,
search_query=search_query,
all_tags=sorted(list(all_tags)),
all_folders=sorted(list(all_folders)),
folder_tree=folder_tree,
folder_counts=folder_counts,
projects=projects,
NoteVisibility=NoteVisibility)
@@ -1695,6 +1750,7 @@ def create_note():
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
visibility = request.form.get('visibility', 'Private')
folder = request.form.get('folder', '').strip()
tags = request.form.get('tags', '').strip()
project_id = request.form.get('project_id')
task_id = request.form.get('task_id')
@@ -1719,6 +1775,7 @@ def create_note():
title=title,
content=content,
visibility=NoteVisibility[visibility.upper()], # Convert to uppercase for enum access
folder=folder if folder else None,
tags=','.join(tag_list) if tag_list else None,
created_by_id=g.user.id,
company_id=g.user.company_id
@@ -1758,11 +1815,26 @@ def create_note():
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
tasks = []
# Get all existing folders for suggestions
all_folders = set()
# Get folders from NoteFolder table
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
for folder in folder_records:
all_folders.add(folder.path)
# Also get folders from notes (for backward compatibility)
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
for note in folder_notes:
if note.folder:
all_folders.add(note.folder)
return render_template('note_editor.html',
title='New Note',
note=None,
projects=projects,
tasks=tasks,
all_folders=sorted(list(all_folders)),
NoteVisibility=NoteVisibility)
@@ -1826,6 +1898,7 @@ def edit_note(slug):
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
visibility = request.form.get('visibility', 'Private')
folder = request.form.get('folder', '').strip()
tags = request.form.get('tags', '').strip()
project_id = request.form.get('project_id')
task_id = request.form.get('task_id')
@@ -1849,6 +1922,7 @@ def edit_note(slug):
note.title = title
note.content = content
note.visibility = NoteVisibility[visibility.upper()] # Convert to uppercase for enum access
note.folder = folder if folder else None
note.tags = ','.join(tag_list) if tag_list else None
# Update team_id if visibility is Team
@@ -1895,11 +1969,26 @@ def edit_note(slug):
if note.project_id:
tasks = Task.query.filter_by(project_id=note.project_id).all()
# Get all existing folders for suggestions
all_folders = set()
# Get folders from NoteFolder table
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
for folder in folder_records:
all_folders.add(folder.path)
# Also get folders from notes (for backward compatibility)
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
for n in folder_notes:
if n.folder:
all_folders.add(n.folder)
return render_template('note_editor.html',
title=f'Edit: {note.title}',
note=note,
projects=projects,
tasks=tasks,
all_folders=sorted(list(all_folders)),
NoteVisibility=NoteVisibility)
@@ -1930,6 +2019,296 @@ def delete_note(slug):
return redirect(url_for('view_note', slug=slug))
@app.route('/notes/folders')
@login_required
@company_required
def notes_folders():
"""Manage note folders"""
# Get all folders from NoteFolder table
all_folders = set()
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
for folder in folder_records:
all_folders.add(folder.path)
# Also get folders from notes (for backward compatibility)
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
folder_counts = {}
for note in folder_notes:
if note.folder and note.can_user_view(g.user):
# Add this folder and all parent folders
parts = note.folder.split('/')
for i in range(len(parts)):
folder_path = '/'.join(parts[:i+1])
all_folders.add(folder_path)
folder_counts[folder_path] = folder_counts.get(folder_path, 0) + (1 if i == len(parts)-1 else 0)
# Initialize counts for empty folders
for folder_path in all_folders:
if folder_path not in folder_counts:
folder_counts[folder_path] = 0
# Build folder tree structure
folder_tree = {}
for folder in sorted(all_folders):
parts = folder.split('/')
current = folder_tree
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Leaf folder
current[folder] = {}
else:
# Navigate to parent
parent_path = '/'.join(parts[:i+1])
if parent_path not in current:
current[parent_path] = {}
current = current[parent_path]
return render_template('notes_folders.html',
title='Note Folders',
all_folders=sorted(list(all_folders)),
folder_tree=folder_tree,
folder_counts=folder_counts)
@app.route('/api/notes/folder-details')
@login_required
@company_required
def api_folder_details():
"""Get details about a specific folder"""
folder_path = request.args.get('path', '')
if not folder_path:
return jsonify({'error': 'Folder path required'}), 400
# Get notes in this folder
notes = Note.query.filter_by(
company_id=g.user.company_id,
folder=folder_path,
is_archived=False
).all()
# Filter by visibility
visible_notes = [n for n in notes if n.can_user_view(g.user)]
# Get subfolders
all_folders = set()
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(
Note.folder.like(f'{folder_path}/%')
).all()
for note in folder_notes:
if note.folder and note.can_user_view(g.user):
# Get immediate subfolder
subfolder = note.folder[len(folder_path)+1:]
if '/' in subfolder:
subfolder = subfolder.split('/')[0]
all_folders.add(subfolder)
# Get recent notes (last 5)
recent_notes = sorted(visible_notes, key=lambda n: n.updated_at, reverse=True)[:5]
return jsonify({
'name': folder_path.split('/')[-1],
'path': folder_path,
'note_count': len(visible_notes),
'subfolder_count': len(all_folders),
'recent_notes': [
{
'title': n.title,
'slug': n.slug,
'updated_at': n.updated_at.strftime('%Y-%m-%d %H:%M')
} for n in recent_notes
]
})
@app.route('/api/notes/folders', methods=['POST'])
@login_required
@company_required
def api_create_folder():
"""Create a new folder"""
data = request.get_json()
folder_name = data.get('name', '').strip()
parent_folder = data.get('parent', '').strip()
if not folder_name:
return jsonify({'success': False, 'message': 'Folder name is required'}), 400
# Validate folder name (no special characters except dash and underscore)
import re
if not re.match(r'^[a-zA-Z0-9_\- ]+$', folder_name):
return jsonify({'success': False, 'message': 'Folder name can only contain letters, numbers, spaces, dashes, and underscores'}), 400
# Create full path
full_path = f"{parent_folder}/{folder_name}" if parent_folder else folder_name
# Check if folder already exists
existing_folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=full_path
).first()
if existing_folder:
return jsonify({'success': False, 'message': 'Folder already exists'}), 400
# Create the folder
try:
folder = NoteFolder(
name=folder_name,
path=full_path,
parent_path=parent_folder if parent_folder else None,
description=data.get('description', ''),
created_by_id=g.user.id,
company_id=g.user.company_id
)
db.session.add(folder)
db.session.commit()
return jsonify({
'success': True,
'message': 'Folder created successfully',
'folder': {
'name': folder_name,
'path': full_path
}
})
except Exception as e:
db.session.rollback()
logger.error(f"Error creating folder: {str(e)}")
return jsonify({'success': False, 'message': 'Error creating folder'}), 500
@app.route('/api/notes/folders', methods=['PUT'])
@login_required
@company_required
def api_rename_folder():
"""Rename an existing folder"""
data = request.get_json()
old_path = data.get('old_path', '').strip()
new_name = data.get('new_name', '').strip()
if not old_path or not new_name:
return jsonify({'success': False, 'message': 'Old path and new name are required'}), 400
# Validate folder name
import re
if not re.match(r'^[a-zA-Z0-9_\- ]+$', new_name):
return jsonify({'success': False, 'message': 'Folder name can only contain letters, numbers, spaces, dashes, and underscores'}), 400
# Build new path
path_parts = old_path.split('/')
path_parts[-1] = new_name
new_path = '/'.join(path_parts)
# Update all notes in this folder and subfolders
notes_to_update = Note.query.filter(
Note.company_id == g.user.company_id,
db.or_(
Note.folder == old_path,
Note.folder.like(f'{old_path}/%')
)
).all()
# Check permissions for all notes
for note in notes_to_update:
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'You do not have permission to modify all notes in this folder'}), 403
# Update folder paths
try:
for note in notes_to_update:
if note.folder == old_path:
note.folder = new_path
else:
# Update subfolder path
note.folder = new_path + note.folder[len(old_path):]
db.session.commit()
return jsonify({
'success': True,
'message': f'Renamed folder to {new_name}',
'updated_count': len(notes_to_update)
})
except Exception as e:
db.session.rollback()
logger.error(f"Error renaming folder: {str(e)}")
return jsonify({'success': False, 'message': 'Error renaming folder'}), 500
@app.route('/api/notes/folders', methods=['DELETE'])
@login_required
@company_required
def api_delete_folder():
"""Delete an empty folder"""
folder_path = request.args.get('path', '').strip()
if not folder_path:
return jsonify({'success': False, 'message': 'Folder path is required'}), 400
# Check if folder has any notes
notes_in_folder = Note.query.filter_by(
company_id=g.user.company_id,
folder=folder_path,
is_archived=False
).all()
if notes_in_folder:
return jsonify({'success': False, 'message': 'Cannot delete folder that contains notes'}), 400
# Check if folder has subfolders with notes
notes_in_subfolders = Note.query.filter(
Note.company_id == g.user.company_id,
Note.folder.like(f'{folder_path}/%'),
Note.is_archived == False
).first()
if notes_in_subfolders:
return jsonify({'success': False, 'message': 'Cannot delete folder that contains subfolders with notes'}), 400
# Since we don't have a separate folders table, we just return success
# The folder will disappear from the UI when there are no notes in it
return jsonify({
'success': True,
'message': 'Folder deleted successfully'
})
@app.route('/api/notes/<slug>/folder', methods=['PUT'])
@login_required
@company_required
def update_note_folder(slug):
"""Update a note's folder via drag and drop"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
folder_path = data.get('folder', '').strip()
try:
# Update the note's folder
note.folder = folder_path if folder_path else None
db.session.commit()
return jsonify({
'success': True,
'message': 'Note moved successfully',
'folder': folder_path
})
except Exception as e:
db.session.rollback()
logger.error(f"Error updating note folder: {str(e)}")
return jsonify({'success': False, 'message': 'Error updating note folder'}), 500
@app.route('/api/notes/<int:note_id>/link', methods=['POST'])
@login_required
@company_required

View File

@@ -1280,7 +1280,21 @@ def migrate_postgresql_schema():
WHERE table_name = 'note'
"""))
if result.fetchone():
# Table exists, check for folder column
result = db.session.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'note' AND column_name = 'folder'
"""))
if not result.fetchone():
print("Adding folder column to note table...")
db.session.execute(text("ALTER TABLE note ADD COLUMN folder VARCHAR(100)"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder)"))
db.session.commit()
print("Folder column added successfully!")
else:
print("Creating note and note_link tables...")
# Create NoteVisibility enum type
@@ -1299,6 +1313,7 @@ def migrate_postgresql_schema():
content TEXT NOT NULL,
slug VARCHAR(100) NOT NULL,
visibility notevisibility NOT NULL DEFAULT 'Private',
folder VARCHAR(100),
company_id INTEGER NOT NULL,
created_by_id INTEGER NOT NULL,
project_id INTEGER,
@@ -1327,6 +1342,31 @@ def migrate_postgresql_schema():
)
"""))
# Check if note_folder table exists
result = db.session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'note_folder'
"""))
if not result.fetchone():
print("Creating note_folder table...")
db.session.execute(text("""
CREATE TABLE note_folder (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
path VARCHAR(500) NOT NULL,
parent_path VARCHAR(500),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
FOREIGN KEY (created_by_id) REFERENCES "user" (id),
FOREIGN KEY (company_id) REFERENCES company (id),
CONSTRAINT uq_folder_path_company UNIQUE (path, company_id)
)
"""))
# Create indexes
db.session.execute(text("CREATE INDEX idx_note_company ON note(company_id)"))
db.session.execute(text("CREATE INDEX idx_note_created_by ON note(created_by_id)"))
@@ -1334,11 +1374,23 @@ def migrate_postgresql_schema():
db.session.execute(text("CREATE INDEX idx_note_task ON note(task_id)"))
db.session.execute(text("CREATE INDEX idx_note_slug ON note(company_id, slug)"))
db.session.execute(text("CREATE INDEX idx_note_visibility ON note(visibility)"))
db.session.execute(text("CREATE INDEX idx_note_archived ON note(archived)"))
db.session.execute(text("CREATE INDEX idx_note_archived ON note(is_archived)"))
db.session.execute(text("CREATE INDEX idx_note_created_at ON note(created_at DESC)"))
db.session.execute(text("CREATE INDEX idx_note_folder ON note(folder)"))
db.session.execute(text("CREATE INDEX idx_note_link_source ON note_link(source_note_id)"))
db.session.execute(text("CREATE INDEX idx_note_link_target ON note_link(target_note_id)"))
# Create indexes for note_folder if table was created
result = db.session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'note_folder'
"""))
if result.fetchone():
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_company ON note_folder(company_id)"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_parent_path ON note_folder(parent_path)"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_created_by ON note_folder(created_by_id)"))
db.session.commit()
print("PostgreSQL schema migration completed successfully!")
@@ -1568,7 +1620,46 @@ def migrate_notes_system(db_file=None):
# Check if note table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note'")
if cursor.fetchone():
print("Note table already exists. Skipping migration.")
print("Note table already exists. Checking for updates...")
# Check if folder column exists
cursor.execute("PRAGMA table_info(note)")
columns = [column[1] for column in cursor.fetchall()]
if 'folder' not in columns:
print("Adding folder column to note table...")
cursor.execute("ALTER TABLE note ADD COLUMN folder VARCHAR(100)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder)")
conn.commit()
print("Folder column added successfully!")
# Check if note_folder table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note_folder'")
if not cursor.fetchone():
print("Creating note_folder table...")
cursor.execute("""
CREATE TABLE note_folder (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
path VARCHAR(500) NOT NULL,
parent_path VARCHAR(500),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
FOREIGN KEY (created_by_id) REFERENCES user(id),
FOREIGN KEY (company_id) REFERENCES company(id),
UNIQUE(path, company_id)
)
""")
# Create indexes for note_folder
cursor.execute("CREATE INDEX idx_note_folder_company ON note_folder(company_id)")
cursor.execute("CREATE INDEX idx_note_folder_parent_path ON note_folder(parent_path)")
cursor.execute("CREATE INDEX idx_note_folder_created_by ON note_folder(created_by_id)")
conn.commit()
print("Note folder table created successfully!")
return True
print("Creating Notes system tables...")
@@ -1581,6 +1672,7 @@ def migrate_notes_system(db_file=None):
content TEXT NOT NULL,
slug VARCHAR(100) NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'Private',
folder VARCHAR(100),
company_id INTEGER NOT NULL,
created_by_id INTEGER NOT NULL,
project_id INTEGER,
@@ -1626,6 +1718,28 @@ def migrate_notes_system(db_file=None):
cursor.execute("CREATE INDEX idx_note_link_source ON note_link(source_note_id)")
cursor.execute("CREATE INDEX idx_note_link_target ON note_link(target_note_id)")
# Create note_folder table
cursor.execute("""
CREATE TABLE note_folder (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
path VARCHAR(500) NOT NULL,
parent_path VARCHAR(500),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
FOREIGN KEY (created_by_id) REFERENCES user(id),
FOREIGN KEY (company_id) REFERENCES company(id),
UNIQUE(path, company_id)
)
""")
# Create indexes for note_folder
cursor.execute("CREATE INDEX idx_note_folder_company ON note_folder(company_id)")
cursor.execute("CREATE INDEX idx_note_folder_parent_path ON note_folder(parent_path)")
cursor.execute("CREATE INDEX idx_note_folder_created_by ON note_folder(created_by_id)")
conn.commit()
print("Notes system migration completed successfully!")
return True

View File

@@ -1261,6 +1261,9 @@ class Note(db.Model):
# Visibility and sharing
visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE)
# Folder organization
folder = db.Column(db.String(100), nullable=True) # Folder path like "Work/Projects" or "Personal"
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
@@ -1418,3 +1421,29 @@ class NoteLink(db.Model):
def __repr__(self):
return f'<NoteLink {self.source_note_id} -> {self.target_note_id}>'
class NoteFolder(db.Model):
"""Represents a folder for organizing notes"""
id = db.Column(db.Integer, primary_key=True)
# Folder properties
name = db.Column(db.String(100), nullable=False)
path = db.Column(db.String(500), nullable=False) # Full path like "Work/Projects/Q1"
parent_path = db.Column(db.String(500), nullable=True) # Parent folder path
description = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id])
company = db.relationship('Company', foreign_keys=[company_id])
# Unique constraint to prevent duplicate paths within a company
__table_args__ = (db.UniqueConstraint('path', 'company_id', name='uq_folder_path_company'),)
def __repr__(self):
return f'<NoteFolder {self.path}>'

View File

@@ -59,6 +59,19 @@
</select>
</div>
<div class="settings-group">
<label for="folder">Folder</label>
<input type="text" id="folder" name="folder" class="form-control"
placeholder="e.g., Work/Projects or Personal"
value="{{ note.folder if note and note.folder else '' }}"
list="folder-suggestions">
<datalist id="folder-suggestions">
{% for folder in all_folders %}
<option value="{{ folder }}">
{% endfor %}
</datalist>
</div>
<div class="settings-group">
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" name="tags" class="form-control"

View File

@@ -0,0 +1,538 @@
{% extends "layout.html" %}
{% block content %}
<div class="timetrack-container notes-folders-container">
<div class="admin-header">
<h2>Note Folders</h2>
<div class="admin-actions">
<button type="button" class="btn btn-sm btn-success" onclick="showCreateFolderModal()">
<span>📁</span> Create Folder
</button>
<a href="{{ url_for('notes_list') }}" class="btn btn-sm btn-secondary">Back to Notes</a>
</div>
</div>
<div class="folders-layout">
<!-- Folder Tree -->
<div class="folder-tree-panel">
<h3>Folder Structure</h3>
<div class="folder-tree" id="folder-tree">
{{ render_folder_tree(folder_tree)|safe }}
</div>
</div>
<!-- Folder Details -->
<div class="folder-details-panel">
<div id="folder-info">
<p class="text-muted">Select a folder to view details</p>
</div>
</div>
</div>
</div>
<!-- Create/Edit Folder Modal -->
<div class="modal" id="folderModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Create New Folder</h3>
<button type="button" class="close-btn" onclick="closeFolderModal()">&times;</button>
</div>
<div class="modal-body">
<form id="folderForm">
<div class="form-group">
<label for="folderName">Folder Name</label>
<input type="text" id="folderName" name="name" class="form-control" required
placeholder="e.g., Projects, Meeting Notes">
</div>
<div class="form-group">
<label for="parentFolder">Parent Folder</label>
<select id="parentFolder" name="parent" class="form-control">
<option value="">Root (Top Level)</option>
{% for folder in all_folders %}
<option value="{{ folder }}">{{ folder }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="folderDescription">Description (Optional)</label>
<textarea id="folderDescription" name="description" class="form-control"
rows="3" placeholder="What kind of notes will go in this folder?"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeFolderModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveFolder()">Save Folder</button>
</div>
</div>
</div>
<style>
.notes-folders-container {
max-width: none !important;
width: 100% !important;
padding: 1rem !important;
margin: 0 !important;
}
.folders-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
min-height: 600px;
}
.folder-tree-panel,
.folder-details-panel {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
}
.folder-tree-panel h3,
.folder-details-panel h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.2rem;
color: #333;
}
/* Folder Tree Styles */
.folder-tree {
font-size: 0.95rem;
}
.folder-item {
position: relative;
margin: 0.25rem 0;
}
.folder-item.has-children > .folder-content::before {
content: "▶";
position: absolute;
left: -15px;
transition: transform 0.2s;
cursor: pointer;
}
.folder-item.has-children.expanded > .folder-content::before {
transform: rotate(90deg);
}
.folder-content {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
margin-left: 1rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.folder-content:hover {
background: #f8f9fa;
}
.folder-content.selected {
background: #e3f2fd;
font-weight: 500;
}
.folder-icon {
margin-right: 0.5rem;
}
.folder-name {
flex: 1;
}
.folder-count {
font-size: 0.85rem;
color: #666;
margin-left: 0.5rem;
}
.folder-children {
margin-left: 1.5rem;
display: none;
}
.folder-item.expanded > .folder-children {
display: block;
}
/* Folder Details */
.folder-details {
padding: 1rem;
}
.folder-details h4 {
margin-top: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.folder-path {
font-size: 0.9rem;
color: #666;
margin-bottom: 1rem;
font-family: monospace;
background: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
}
.folder-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.stat-box {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 0.85rem;
color: #666;
margin-top: 0.25rem;
}
.folder-actions {
margin-top: 2rem;
display: flex;
gap: 0.5rem;
}
.notes-preview {
margin-top: 2rem;
}
.notes-preview h5 {
margin-bottom: 1rem;
color: #333;
}
.note-preview-item {
padding: 0.75rem;
background: #f8f9fa;
border-radius: 4px;
margin-bottom: 0.5rem;
transition: background 0.2s;
}
.note-preview-item:hover {
background: #e9ecef;
}
.note-preview-title {
font-weight: 500;
color: #333;
text-decoration: none;
display: block;
}
.note-preview-date {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
/* Modal Styles */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
width: 90%;
max-width: 500px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
}
.modal-header h3 {
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.folders-layout {
grid-template-columns: 1fr;
}
.folder-tree-panel {
max-height: 300px;
overflow-y: auto;
}
}
</style>
<script>
let selectedFolder = null;
function selectFolder(folderPath) {
// Remove previous selection
document.querySelectorAll('.folder-content').forEach(el => {
el.classList.remove('selected');
});
// Add selection to clicked folder
event.currentTarget.classList.add('selected');
selectedFolder = folderPath;
// Load folder details
loadFolderDetails(folderPath);
}
function toggleFolder(event, folderPath) {
event.stopPropagation();
const folderItem = event.currentTarget.closest('.folder-item');
folderItem.classList.toggle('expanded');
}
function loadFolderDetails(folderPath) {
fetch(`/api/notes/folder-details?path=${encodeURIComponent(folderPath)}`)
.then(response => response.json())
.then(data => {
const detailsHtml = `
<div class="folder-details">
<h4><span class="folder-icon">📁</span> ${data.name}</h4>
<div class="folder-path">${data.path}</div>
<div class="folder-stats">
<div class="stat-box">
<div class="stat-value">${data.note_count}</div>
<div class="stat-label">Notes</div>
</div>
<div class="stat-box">
<div class="stat-value">${data.subfolder_count}</div>
<div class="stat-label">Subfolders</div>
</div>
</div>
<div class="folder-actions">
<a href="/notes?folder=${encodeURIComponent(data.path)}" class="btn btn-sm btn-primary">
View Notes
</a>
<button type="button" class="btn btn-sm btn-info" onclick="editFolder('${data.path}')">
Rename
</button>
${data.note_count === 0 && data.subfolder_count === 0 ?
`<button type="button" class="btn btn-sm btn-danger" onclick="deleteFolder('${data.path}')">
Delete
</button>` : ''}
</div>
${data.recent_notes.length > 0 ? `
<div class="notes-preview">
<h5>Recent Notes</h5>
${data.recent_notes.map(note => `
<div class="note-preview-item">
<a href="/notes/${note.slug}" class="note-preview-title">${note.title}</a>
<div class="note-preview-date">${note.updated_at}</div>
</div>
`).join('')}
</div>
` : ''}
</div>
`;
document.getElementById('folder-info').innerHTML = detailsHtml;
})
.catch(error => {
console.error('Error loading folder details:', error);
});
}
function showCreateFolderModal() {
document.getElementById('modalTitle').textContent = 'Create New Folder';
document.getElementById('folderForm').reset();
document.getElementById('folderModal').style.display = 'flex';
}
function closeFolderModal() {
document.getElementById('folderModal').style.display = 'none';
}
function saveFolder() {
const formData = new FormData(document.getElementById('folderForm'));
const data = {
name: formData.get('name'),
parent: formData.get('parent'),
description: formData.get('description')
};
// Check if we're editing or creating
const modalTitle = document.getElementById('modalTitle').textContent;
const isEditing = modalTitle.includes('Edit');
if (isEditing && selectedFolder) {
// Rename folder
fetch('/api/notes/folders', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
old_path: selectedFolder,
new_name: data.name
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert(result.message);
window.location.reload();
} else {
alert('Error: ' + result.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error renaming folder');
});
} else {
// Create new folder
fetch('/api/notes/folders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert(result.message);
window.location.reload();
} else {
alert('Error: ' + result.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error creating folder');
});
}
}
function editFolder(folderPath) {
const parts = folderPath.split('/');
const folderName = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
document.getElementById('modalTitle').textContent = 'Edit Folder';
document.getElementById('folderName').value = folderName;
document.getElementById('parentFolder').value = parentPath;
document.getElementById('folderModal').style.display = 'flex';
}
function deleteFolder(folderPath) {
if (confirm(`Are you sure you want to delete the folder "${folderPath}"? This cannot be undone.`)) {
fetch(`/api/notes/folders?path=${encodeURIComponent(folderPath)}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert(result.message);
window.location.reload();
} else {
alert('Error: ' + result.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error deleting folder');
});
}
}
// Close modal when clicking outside
document.getElementById('folderModal').addEventListener('click', function(e) {
if (e.target === this) {
closeFolderModal();
}
});
</script>
{% endblock %}
{% macro render_folder_tree(tree, level=0) %}
{% for folder, children in tree.items() %}
<div class="folder-item {% if children %}has-children{% endif %}" data-folder="{{ folder }}">
<div class="folder-content" onclick="selectFolder('{{ folder }}')">
{% if children %}
<span onclick="toggleFolder(event, '{{ folder }}')" style="position: absolute; left: -15px; cursor: pointer;"></span>
{% endif %}
<span class="folder-icon">📁</span>
<span class="folder-name">{{ folder.split('/')[-1] }}</span>
<span class="folder-count">({{ folder_counts.get(folder, 0) }})</span>
</div>
{% if children %}
<div class="folder-children">
{{ render_folder_tree(children, level + 1)|safe }}
</div>
{% endif %}
</div>
{% endfor %}
{% endmacro %}

File diff suppressed because it is too large Load Diff