first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
notes/**
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.db
|
||||
*.sqlite3
|
||||
*.log
|
||||
*.bak
|
||||
@@ -0,0 +1,246 @@
|
||||
# app.py
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify
|
||||
import os
|
||||
import mistune
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
def generate_slug(text):
|
||||
# Remove non-alphanumeric characters, replace spaces with hyphens, and lowercase
|
||||
slug = re.sub(r'[^a-zA-Z0-9\s]', '', text).strip().lower()
|
||||
slug = re.sub(r'\s+', '-', slug)
|
||||
return quote(slug) # URL-encode for safety
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['NOTES_DIR'] = Path('notes') # Store Markdown files here
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||
|
||||
# Create directory for notes if it doesn't exist
|
||||
if not app.config['NOTES_DIR'].exists():
|
||||
os.makedirs(app.config['NOTES_DIR'])
|
||||
|
||||
def build_tree_structure(directory, base_path=None, level=0):
|
||||
"""Build a tree structure from the directory."""
|
||||
if base_path is None:
|
||||
base_path = directory
|
||||
|
||||
tree = []
|
||||
try:
|
||||
for item in sorted(directory.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
||||
if item.name.startswith('.'): # Skip hidden files
|
||||
continue
|
||||
|
||||
rel_path = str(item.relative_to(base_path))
|
||||
node = {
|
||||
'name': item.name,
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'path': rel_path,
|
||||
'level': level,
|
||||
'expanded': False # Default to collapsed
|
||||
}
|
||||
|
||||
if item.is_dir():
|
||||
node['children'] = build_tree_structure(item, base_path, level + 1)
|
||||
elif item.suffix == '.md': # Only include .md files
|
||||
node['display_name'] = item.stem # Just use filename without extension
|
||||
else:
|
||||
continue # Skip non-markdown files
|
||||
|
||||
tree.append(node)
|
||||
except Exception as e:
|
||||
print(f"Error reading directory {directory}: {str(e)}")
|
||||
|
||||
return tree
|
||||
|
||||
def get_path_components(path):
|
||||
"""Convert a file path into a list of directory names."""
|
||||
if not path:
|
||||
return []
|
||||
parts = path.split('/')
|
||||
return ['/'.join(parts[:i+1]) for i in range(len(parts)-1)]
|
||||
|
||||
def generate_breadcrumbs(note_path, title=None):
|
||||
"""Generate breadcrumb data for a given note path."""
|
||||
crumbs = []
|
||||
parts = note_path.split('/')
|
||||
current_path = ''
|
||||
|
||||
# Add root
|
||||
crumbs.append({'name': '/', 'url': url_for('index')})
|
||||
|
||||
# Add directories
|
||||
for i, part in enumerate(parts[:-1]): # Skip the file name
|
||||
current_path = current_path + '/' + part if current_path else part
|
||||
crumbs.append({
|
||||
'name': part,
|
||||
'url': url_for('index') + '#' + current_path # Using hash for directory navigation
|
||||
})
|
||||
|
||||
# Add the file name (use title if provided)
|
||||
if len(parts) > 0:
|
||||
if title:
|
||||
crumbs.append({'name': title, 'url': None})
|
||||
else:
|
||||
crumbs.append({'name': parts[-1][:-3], 'url': None}) # Remove .md extension
|
||||
|
||||
return crumbs
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||
return render_template('index.html',
|
||||
notes_tree=notes_tree,
|
||||
active_path=[],
|
||||
current_note=None,
|
||||
breadcrumbs=generate_breadcrumbs(''))
|
||||
|
||||
@app.route('/note/<path:note_path>')
|
||||
def note(note_path):
|
||||
full_path = Path(app.config['NOTES_DIR']) / note_path
|
||||
if not full_path.is_file() or not note_path.endswith('.md'):
|
||||
return "Note not found", 404
|
||||
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
title = full_path.stem
|
||||
if '##' in content:
|
||||
title = content.split('##')[1].split('\n')[0].strip()
|
||||
|
||||
# Convert markdown to HTML
|
||||
renderer = mistune.HTMLRenderer()
|
||||
markdown_parser = mistune.Markdown(renderer=renderer)
|
||||
html_content = markdown_parser(content)
|
||||
|
||||
# Build tree and get path components
|
||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||
active_path = get_path_components(note_path)
|
||||
breadcrumbs = generate_breadcrumbs(note_path, title)
|
||||
|
||||
return render_template('note.html',
|
||||
content=content,
|
||||
html_content=html_content,
|
||||
title=title,
|
||||
notes_tree=notes_tree,
|
||||
active_path=active_path,
|
||||
current_note=note_path,
|
||||
breadcrumbs=breadcrumbs)
|
||||
except Exception as e:
|
||||
return f"Error reading note: {str(e)}", 500
|
||||
|
||||
@app.route('/edit/<path:note_path>', methods=['GET', 'POST'])
|
||||
def edit_note(note_path):
|
||||
full_path = Path(app.config['NOTES_DIR']) / note_path
|
||||
if not full_path.is_file() or not note_path.endswith('.md'):
|
||||
return "Note not found", 404
|
||||
|
||||
if request.method == 'POST':
|
||||
new_content = request.form.get('content', '')
|
||||
try:
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
return redirect(url_for('note', note_path=note_path))
|
||||
except Exception as e:
|
||||
return f"Error saving note: {str(e)}", 500
|
||||
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
title = full_path.stem
|
||||
if '##' in content:
|
||||
title = content.split('##')[1].split('\n')[0].strip()
|
||||
|
||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||
active_path = get_path_components(note_path)
|
||||
breadcrumbs = generate_breadcrumbs(note_path, f"Edit {title}")
|
||||
|
||||
return render_template('edit.html',
|
||||
content=content,
|
||||
title=title,
|
||||
notes_tree=notes_tree,
|
||||
active_path=active_path,
|
||||
current_note=note_path,
|
||||
breadcrumbs=breadcrumbs)
|
||||
except Exception as e:
|
||||
return f"Error reading note: {str(e)}", 500
|
||||
|
||||
@app.route('/create', methods=['GET', 'POST'])
|
||||
def create_note():
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', '').strip() or 'Untitled Note'
|
||||
content = request.form.get('content', '').strip()
|
||||
path = request.form.get('path', '').strip()
|
||||
|
||||
# Generate filename
|
||||
filename = f"{generate_slug(title)}.md"
|
||||
if path:
|
||||
# Create directories if they don't exist
|
||||
dir_path = Path(app.config['NOTES_DIR']) / path
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
file_path = dir_path / filename
|
||||
else:
|
||||
file_path = Path(app.config['NOTES_DIR']) / filename
|
||||
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"## {title}\n\n{content}")
|
||||
return redirect(url_for('note', note_path=str(file_path.relative_to(app.config['NOTES_DIR']))))
|
||||
except Exception as e:
|
||||
return f"Error creating note: {str(e)}", 500
|
||||
|
||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||
return render_template('create.html', notes_tree=notes_tree, active_path=[], current_note=None)
|
||||
|
||||
@app.route('/delete/<path:note_path>', methods=['POST'])
|
||||
def delete_note(note_path):
|
||||
full_path = Path(app.config['NOTES_DIR']) / note_path
|
||||
if not full_path.is_file() or not note_path.endswith('.md'):
|
||||
return "Note not found", 404
|
||||
|
||||
try:
|
||||
os.remove(full_path)
|
||||
return redirect(url_for('index'))
|
||||
except Exception as e:
|
||||
return f"Error deleting note: {str(e)}", 500
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
query = request.args.get('q', '').lower()
|
||||
if not query:
|
||||
return jsonify([])
|
||||
|
||||
results = []
|
||||
for root, _, files in os.walk(app.config['NOTES_DIR']):
|
||||
for file in files:
|
||||
if not file.endswith('.md'):
|
||||
continue
|
||||
|
||||
try:
|
||||
file_path = Path(root) / file
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if query in content.lower():
|
||||
title = file_path.stem
|
||||
if '##' in content:
|
||||
title = content.split('##')[1].split('\n')[0].strip()
|
||||
|
||||
# Find context for the match
|
||||
pos = content.lower().find(query)
|
||||
start = max(0, pos - 50)
|
||||
end = min(len(content), pos + len(query) + 50)
|
||||
snippet = content[start:end].strip()
|
||||
|
||||
results.append({
|
||||
'title': title,
|
||||
'url': url_for('note', note_path=str(file_path.relative_to(app.config['NOTES_DIR']))),
|
||||
'snippet': snippet
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error reading {file_path}: {str(e)}")
|
||||
|
||||
return jsonify(results)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
@@ -0,0 +1,236 @@
|
||||
body { background: #181a1b; color: #f1f1f1; }
|
||||
.navbar { margin-bottom: 0; }
|
||||
.navbar-dark { background: #23272b !important; }
|
||||
.container { max-width: 100vw; }
|
||||
.sidebar {
|
||||
background: #23272b !important;
|
||||
color: #f1f1f1 !important;
|
||||
min-height: 100vh;
|
||||
padding: 0.5rem 0.5rem 1rem 0.5rem;
|
||||
font-size: 0.97em;
|
||||
width: 220px;
|
||||
max-width: 350px;
|
||||
min-width: 48px;
|
||||
overflow: auto;
|
||||
border-right: 1px solid #343a40;
|
||||
transition: width 0.2s, min-width 0.2s;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
float: left;
|
||||
}
|
||||
.sidebar .resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
background: transparent;
|
||||
z-index: 20;
|
||||
}
|
||||
.sidebar .resize-handle:hover {
|
||||
background: #343a40;
|
||||
}
|
||||
.sidebar.minimized .resize-handle {
|
||||
display: none;
|
||||
}
|
||||
.sidebar.minimized {
|
||||
width: 48px !important;
|
||||
min-width: 48px !important;
|
||||
max-width: 48px !important;
|
||||
overflow-x: hidden;
|
||||
padding-left: 0.2rem;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
.sidebar .sidebar-toggle {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: -16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #23272b;
|
||||
border: 1px solid #343a40;
|
||||
border-radius: 50%;
|
||||
color: #aaa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.sidebar .sidebar-toggle:hover {
|
||||
background: #343a40;
|
||||
color: #fff;
|
||||
}
|
||||
.sidebar .notes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.sidebar .notes-header .btn {
|
||||
font-size: 0.95em;
|
||||
padding: 0.3em 0.7em;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar .nav-link { color: #b3aaff; padding: 0.35rem 0.5rem; border-radius: 4px; }
|
||||
.sidebar .nav-link.active, .sidebar .nav-link:hover { color: #fff; background: #343a40; }
|
||||
.sidebar ul { margin-bottom: 0.5em; }
|
||||
.sidebar .nav-item { margin-bottom: 0.2em; }
|
||||
.sidebar.minimized .notes-header .btn,
|
||||
.sidebar.minimized .notes-header .notes-title,
|
||||
.sidebar.minimized ul,
|
||||
.sidebar.minimized .nav-item,
|
||||
.sidebar.minimized .nav-link {
|
||||
display: none !important;
|
||||
}
|
||||
.sidebar.minimized .sidebar-toggle {
|
||||
right: -16px;
|
||||
}
|
||||
.sidebar .notes-header .notes-title {
|
||||
font-size: 1.1em;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
.card, .list-group-item, .form-control, .btn, .alert {
|
||||
background-color: #23272b !important;
|
||||
color: #f1f1f1 !important;
|
||||
border-color: #343a40 !important;
|
||||
}
|
||||
.form-label { color: #f1f1f1; }
|
||||
.btn-primary { background-color: #6c63ff; border-color: #6c63ff; }
|
||||
.btn-outline-secondary, .btn-outline-danger { color: #f1f1f1; border-color: #6c757d; }
|
||||
.btn-outline-secondary:hover, .btn-outline-danger:hover { background: #343a40; }
|
||||
.list-group-item a { color: #6c63ff; text-decoration: none; }
|
||||
.list-group-item a:hover { text-decoration: underline; }
|
||||
footer { color: #aaa; }
|
||||
/* Rendered Markdown Preview Styling */
|
||||
.editor-preview, .editor-preview-active, .editor-preview-side, .markdown-body, .card-body {
|
||||
background: #181a1b !important;
|
||||
color: #f1f1f1 !important;
|
||||
}
|
||||
.editor-preview h1, .editor-preview h2, .editor-preview h3, .editor-preview h4, .editor-preview h5, .editor-preview h6,
|
||||
.editor-preview-active h1, .editor-preview-active h2, .editor-preview-active h3, .editor-preview-active h4, .editor-preview-active h5, .editor-preview-active h6 {
|
||||
color: #b3aaff !important;
|
||||
}
|
||||
.editor-preview a, .editor-preview-active a {
|
||||
color: #6c63ff !important;
|
||||
}
|
||||
.editor-preview table, .editor-preview th, .editor-preview td,
|
||||
.editor-preview-active table, .editor-preview-active th, .editor-preview-active td {
|
||||
background: #23272b !important;
|
||||
color: #f1f1f1 !important;
|
||||
border-color: #343a40 !important;
|
||||
}
|
||||
.editor-preview blockquote, .editor-preview-active blockquote {
|
||||
background: #23272b !important;
|
||||
color: #b3aaff !important;
|
||||
border-left: 4px solid #6c63ff !important;
|
||||
}
|
||||
.editor-preview code, .editor-preview pre, .editor-preview-active code, .editor-preview-active pre {
|
||||
background: #23272b !important;
|
||||
color: #f1f1f1 !important;
|
||||
}
|
||||
/* EasyMDE input area */
|
||||
.CodeMirror {
|
||||
background: #23272b !important;
|
||||
color: #f1f1f1 !important;
|
||||
}
|
||||
.CodeMirror-cursor {
|
||||
border-left: 1px solid #f1f1f1 !important;
|
||||
}
|
||||
.CodeMirror-gutters {
|
||||
background: #23272b !important;
|
||||
border-right: 1px solid #343a40 !important;
|
||||
}
|
||||
/* EasyMDE toolbar and dropdowns */
|
||||
.editor-toolbar, .editor-toolbar.fullscreen {
|
||||
background: #23272b !important;
|
||||
border-color: #343a40 !important;
|
||||
}
|
||||
.editor-toolbar a, .editor-toolbar button {
|
||||
color: #f1f1f1 !important;
|
||||
filter: invert(0.85) brightness(1.5) !important;
|
||||
}
|
||||
.editor-toolbar a.active, .editor-toolbar button.active, .editor-toolbar a:hover, .editor-toolbar button:hover {
|
||||
background: #343a40 !important;
|
||||
color: #6c63ff !important;
|
||||
}
|
||||
.editor-toolbar .editor-toolbar-dropdown {
|
||||
background: #23272b !important;
|
||||
color: #f1f1f1 !important;
|
||||
border-color: #343a40 !important;
|
||||
}
|
||||
.editor-toolbar .editor-toolbar-dropdown a {
|
||||
color: #f1f1f1 !important;
|
||||
}
|
||||
.editor-toolbar .editor-toolbar-dropdown a:hover {
|
||||
background: #343a40 !important;
|
||||
color: #6c63ff !important;
|
||||
}
|
||||
.editor-statusbar {
|
||||
background: #23272b !important;
|
||||
color: #aaa !important;
|
||||
border-top: 1px solid #343a40 !important;
|
||||
}
|
||||
/* Scrollbar styling for dark mode */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: #23272b;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #343a40;
|
||||
border-radius: 4px;
|
||||
}
|
||||
pre code, .editor-preview pre code, .editor-preview-active pre code {
|
||||
color: #b3e1ff !important;
|
||||
background: #23272b !important;
|
||||
font-size: 1em;
|
||||
}
|
||||
code { color: #b3e1ff !important; }
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
transition: margin-left 0.2s;
|
||||
}
|
||||
/* File Tree Styles */
|
||||
.file-tree {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.file-tree-dir:hover > .file-tree-toggle {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.file-tree-toggle {
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.file-tree-file .nav-link {
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.file-tree-file .nav-link:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.file-tree-file .nav-link.active {
|
||||
background: rgba(23, 162, 184, 0.2);
|
||||
color: #17a2b8 !important;
|
||||
}
|
||||
.file-tree-toggle i.fa-folder {
|
||||
color: #ffc107;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.file-tree-toggle i.fa-folder-open {
|
||||
color: #17a2b8;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.file-tree-file i {
|
||||
color: #6c757d;
|
||||
}
|
||||
.file-tree-children {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<!-- Search Modal -->
|
||||
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light">
|
||||
<div class="modal-header border-0 pb-1">
|
||||
<h5 class="modal-title" id="searchModalLabel">Search Notes</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-1">
|
||||
<input type="text" id="modal-search-input" class="form-control mb-3" placeholder="Search all notes...">
|
||||
<div id="modal-search-results" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Modal JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var searchModal = document.getElementById('searchModal');
|
||||
var openSearchBtn = document.getElementById('open-search-modal');
|
||||
var modalInput = document.getElementById('modal-search-input');
|
||||
var modalResults = document.getElementById('modal-search-results');
|
||||
if (openSearchBtn && searchModal && modalInput && modalResults) {
|
||||
openSearchBtn.addEventListener('click', function() {
|
||||
var modal = new bootstrap.Modal(searchModal);
|
||||
modal.show();
|
||||
setTimeout(function() { modalInput.focus(); }, 300);
|
||||
});
|
||||
modalInput.addEventListener('input', function() {
|
||||
var q = modalInput.value.trim();
|
||||
if (!q) { modalResults.innerHTML = ''; return; }
|
||||
// AJAX search
|
||||
fetch(`/search?q=${encodeURIComponent(q)}`)
|
||||
.then(r => r.json())
|
||||
.then(results => {
|
||||
if (!results.length) { modalResults.innerHTML = '<div class="text-muted small">No results found.</div>'; return; }
|
||||
modalResults.innerHTML = results.map(r =>
|
||||
`<div class="mb-2">
|
||||
<a href="${r.url}" class="fw-bold text-primary">${r.title}</a><br>
|
||||
<span class="small text-light">...${r.snippet}...</span>
|
||||
</div>`
|
||||
).join('');
|
||||
});
|
||||
});
|
||||
// Clear results/input on close
|
||||
searchModal.addEventListener('hidden.bs.modal', function() {
|
||||
modalInput.value = '';
|
||||
modalResults.innerHTML = '';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,309 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Flask Blog{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css">
|
||||
<link type="text/css" href="{{ url_for('static', filename='css/app_custom_css.css' ) }}" rel="stylesheet">
|
||||
<script>
|
||||
// Apply sidebar width and minimized state before page paint to prevent flicker
|
||||
(function() {
|
||||
try {
|
||||
var savedWidth = localStorage.getItem('sidebar-width');
|
||||
var minimized = localStorage.getItem('sidebar-minimized') === 'true';
|
||||
var style = document.createElement('style');
|
||||
var css = '';
|
||||
if (minimized) {
|
||||
css += '#sidebar { width: 48px !important; min-width: 48px !important; max-width: 48px !important; overflow-x: hidden; padding-left: 0.2rem; padding-right: 0.2rem; transition: none !important; }';
|
||||
} else if (savedWidth) {
|
||||
css += '#sidebar { width: ' + savedWidth + ' !important; transition: none !important; }';
|
||||
}
|
||||
if (css) {
|
||||
style.innerHTML = css;
|
||||
style.setAttribute('id', 'sidebar-initial-style');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body style="overflow: hidden;">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('index') }}">Flask Blog</a>
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{# <li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('create_note') }}">Create Note</a>
|
||||
</li> #}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container-fluid" style="height: calc(100vh - 56px);">
|
||||
<div class="row" style="flex-wrap:nowrap; height: 100%;">
|
||||
<aside id="sidebar" class="sidebar" style="height: 100%; overflow-y: auto;">
|
||||
<div class="d-flex align-items-center gap-2 mb-3" style="margin-top: 0.2em;">
|
||||
<a href="{{ url_for('create_note') }}" class="btn btn-primary btn-sm px-2 py-1" title="New Note" style="font-size:1.1em;"><i class="fa fa-plus"></i></a>
|
||||
<button id="open-search-modal" class="btn btn-outline-secondary btn-sm px-2 py-1" title="Search" style="font-size:1.1em;"><i class="fa fa-search"></i></button>
|
||||
<button id="toggle-all-dirs" class="btn btn-outline-secondary btn-sm px-2 py-1" title="Expand/Collapse All" style="font-size:1.1em;"><i class="fa fa-folder"></i></button>
|
||||
<button class="sidebar-toggle btn btn-outline-secondary btn-sm px-2 py-1" title="Toggle Sidebar" style="font-size:1.1em;margin-left:auto;"><i class="fa fa-angle-double-left"></i></button>
|
||||
</div>
|
||||
<div class="resize-handle" style="position:absolute; right:0; top:0; bottom:0; width:4px; cursor:ew-resize;"></div>
|
||||
<ul class="nav flex-column file-tree">
|
||||
{% if notes_tree and notes_tree|length > 0 %}
|
||||
{% macro render_tree(tree, current_path='', level=0) %}
|
||||
{% for node in tree %}
|
||||
{% if node.type == 'dir' %}
|
||||
{% set dir_path = current_path + '/' + node.name if current_path else node.name %}
|
||||
<li class="nav-item file-tree-dir">
|
||||
<span class="file-tree-toggle" tabindex="0" data-path="{{ dir_path }}" style="position: relative; display: block; padding: 6px 0;">
|
||||
<div style="padding-left: {{ level * 16 }}px;">
|
||||
<!-- Tree line indicators -->
|
||||
{% if level > 0 %}
|
||||
<span style="position: absolute; left: {{ (level - 1) * 16 + 7 }}px; top: 0; bottom: 0; border-left: 1px dotted rgba(255,255,255,0.1);"></span>
|
||||
<span style="position: absolute; left: {{ (level - 1) * 16 + 7 }}px; width: 9px; top: 50%; border-top: 1px dotted rgba(255,255,255,0.1);"></span>
|
||||
{% endif %}
|
||||
<!-- Folder icon and name -->
|
||||
<i class="fa {% if active_path and dir_path in active_path %}fa-folder-open text-info{% else %}fa-folder text-warning{% endif %}" style="margin-right: 5px; width: 16px; text-align: center;"></i>
|
||||
<span style="margin-left: 2px; opacity: 0.9;">{{ node.name }}</span>
|
||||
</div>
|
||||
</span>
|
||||
<ul class="nav flex-column file-tree-children" style="display: {% if active_path and dir_path in active_path %}block{% else %}none{% endif %};">
|
||||
{{ render_tree(node.children, dir_path, level + 1) }}
|
||||
</ul>
|
||||
</li>
|
||||
{% elif node.type == 'file' and node.name.endswith('.md') %}
|
||||
<li class="nav-item file-tree-file">
|
||||
<a class="nav-link {% if node.path == current_note %}active{% endif %}"
|
||||
href="{{ url_for('note', note_path=node.path) }}"
|
||||
style="position: relative; display: block; padding: 4px 0;">
|
||||
<div style="padding-left: {{ level * 16 }}px;">
|
||||
<!-- Tree line indicators -->
|
||||
{% if level > 0 %}
|
||||
<span style="position: absolute; left: {{ (level - 1) * 16 + 7 }}px; top: 0; bottom: 0; border-left: 1px dotted rgba(255,255,255,0.1);"></span>
|
||||
<span style="position: absolute; left: {{ (level - 1) * 16 + 7 }}px; width: 9px; top: 50%; border-top: 1px dotted rgba(255,255,255,0.1);"></span>
|
||||
{% endif %}
|
||||
<!-- File icon and name -->
|
||||
<i class="fa fa-file-text-o" style="margin-right: 5px; width: 16px; text-align: center; opacity: 0.7;"></i>
|
||||
<span style="opacity: 0.85;">{{ node.display_name if node.display_name else node.name[:-3] }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
{{ render_tree(notes_tree) }}
|
||||
{% else %}
|
||||
<li class="nav-item text-muted">No notes found.</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</aside>
|
||||
<main class="main-content col py-4" style="height: 100%; overflow-y: auto;">
|
||||
<!-- Compact Breadcrumbs inside main content -->
|
||||
<nav aria-label="breadcrumb" style="margin-bottom: 0.7em;">
|
||||
<ol class="breadcrumb p-0 m-0 bg-transparent" style="font-size: 0.97em;">
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li class="breadcrumb-item {% if loop.last %}active{% endif %}" {% if loop.last %}aria-current="page"{% endif %} style="padding-right: 0;">
|
||||
{% if crumb.url %}
|
||||
<a href="{{ crumb.url }}" class="text-info text-decoration-none" style="opacity: 0.9; transition: opacity 0.2s, color 0.2s;" onmouseover="this.style.opacity='1'; this.style.color='#17a2b8';" onmouseout="this.style.opacity='0.9'; this.style.color='';">{{ crumb.name }}</a>
|
||||
{% else %}
|
||||
<span class="text-light" style="opacity: 0.95;">{{ crumb.name }}</span>
|
||||
{% endif %}
|
||||
{% if not loop.last %}
|
||||
<span class="text-light" style="opacity: 0.6; margin: 0 0.3em;">></span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="alert alert-info">{{ messages[0] }}</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="text-center mt-3 mb-2 text-muted" style="position: absolute; width: 100%; bottom: 0; left: 0;">
|
||||
© 2025 Flask Blog
|
||||
</footer>
|
||||
|
||||
<!-- Move Bootstrap JS above custom scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Move main JS to end of body -->
|
||||
<script>
|
||||
// Highlight code blocks in EasyMDE preview
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Sidebar width and minimize persistence
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar) return; // Prevent errors if sidebar is missing
|
||||
const toggleBtn = sidebar.querySelector('.sidebar-toggle');
|
||||
const resizeHandle = sidebar.querySelector('.resize-handle');
|
||||
const savedWidth = localStorage.getItem('sidebar-width');
|
||||
const minimized = localStorage.getItem('sidebar-minimized') === 'true';
|
||||
// Remove initial style tag if present
|
||||
const initialStyle = document.getElementById('sidebar-initial-style');
|
||||
if (initialStyle) initialStyle.remove();
|
||||
// Set initial state
|
||||
if (savedWidth && !minimized) sidebar.style.width = savedWidth;
|
||||
if (minimized) {
|
||||
sidebar.classList.add('minimized');
|
||||
sidebar.style.width = '48px';
|
||||
sidebar.style.minWidth = '48px';
|
||||
sidebar.style.maxWidth = '48px';
|
||||
sidebar.style.paddingLeft = '0.2rem';
|
||||
sidebar.style.paddingRight = '0.2rem';
|
||||
toggleBtn.innerHTML = '<i class="fa fa-angle-double-right"></i>';
|
||||
} else {
|
||||
sidebar.classList.remove('minimized');
|
||||
sidebar.style.minWidth = '';
|
||||
sidebar.style.maxWidth = '';
|
||||
sidebar.style.paddingLeft = '';
|
||||
sidebar.style.paddingRight = '';
|
||||
toggleBtn.innerHTML = '<i class="fa fa-angle-double-left"></i>';
|
||||
}
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
const isMin = sidebar.classList.toggle('minimized');
|
||||
localStorage.setItem('sidebar-minimized', isMin);
|
||||
if (isMin) {
|
||||
sidebar.style.width = '48px';
|
||||
sidebar.style.minWidth = '48px';
|
||||
sidebar.style.maxWidth = '48px';
|
||||
sidebar.style.paddingLeft = '0.2rem';
|
||||
sidebar.style.paddingRight = '0.2rem';
|
||||
toggleBtn.innerHTML = '<i class="fa fa-angle-double-right"></i>';
|
||||
} else {
|
||||
const width = localStorage.getItem('sidebar-width') || '220px';
|
||||
sidebar.style.width = width;
|
||||
sidebar.style.minWidth = '';
|
||||
sidebar.style.maxWidth = '';
|
||||
sidebar.style.paddingLeft = '';
|
||||
sidebar.style.paddingRight = '';
|
||||
toggleBtn.innerHTML = '<i class="fa fa-angle-double-left"></i>';
|
||||
}
|
||||
});
|
||||
// Sidebar resizing with handle
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
resizeHandle.addEventListener('mousedown', function(e) {
|
||||
if (!sidebar.classList.contains('minimized')) {
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startWidth = sidebar.offsetWidth;
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isResizing) return;
|
||||
let newWidth = startWidth + (e.clientX - startX);
|
||||
newWidth = Math.max(48, Math.min(350, newWidth));
|
||||
sidebar.style.width = newWidth + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', function() {
|
||||
if (isResizing) {
|
||||
isResizing = false;
|
||||
document.body.style.cursor = '';
|
||||
if (!sidebar.classList.contains('minimized')) {
|
||||
localStorage.setItem('sidebar-width', sidebar.style.width);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Highlight.js for EasyMDE preview using MutationObserver
|
||||
const observer = new MutationObserver(function() {
|
||||
document.querySelectorAll('.editor-preview pre code, .editor-preview-active pre code').forEach(function(block) {
|
||||
if (!block.classList.contains('hljs')) {
|
||||
hljs.highlightElement(block);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.body, { subtree: true, childList: true });
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// File tree expand/collapse with state persistence
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const toggleAllBtn = document.getElementById('toggle-all-dirs');
|
||||
let isAllExpanded = false;
|
||||
|
||||
// Load expanded folders from localStorage
|
||||
let expandedFolders = new Set(JSON.parse(localStorage.getItem('expanded-folders') || '[]'));
|
||||
|
||||
// Initialize folders state
|
||||
document.querySelectorAll('.file-tree-toggle').forEach(function(toggle) {
|
||||
const path = toggle.getAttribute('data-path');
|
||||
const parent = toggle.parentElement;
|
||||
const children = parent.querySelector('.file-tree-children');
|
||||
const icon = toggle.querySelector('i');
|
||||
|
||||
if (expandedFolders.has(path)) {
|
||||
children.style.display = 'block';
|
||||
icon.className = 'fa fa-folder-open text-info';
|
||||
}
|
||||
});
|
||||
|
||||
// Individual folder toggle with state persistence
|
||||
document.querySelectorAll('.file-tree-toggle').forEach(function(toggle) {
|
||||
toggle.addEventListener('click', function() {
|
||||
const path = this.getAttribute('data-path');
|
||||
const parent = toggle.parentElement;
|
||||
const children = parent.querySelector('.file-tree-children');
|
||||
const icon = toggle.querySelector('i');
|
||||
|
||||
if (children) {
|
||||
const isOpen = children.style.display === 'block';
|
||||
children.style.display = isOpen ? 'none' : 'block';
|
||||
icon.className = isOpen ? 'fa fa-folder text-warning' : 'fa fa-folder-open text-info';
|
||||
|
||||
// Update localStorage
|
||||
if (isOpen) {
|
||||
expandedFolders.delete(path);
|
||||
} else {
|
||||
expandedFolders.add(path);
|
||||
}
|
||||
localStorage.setItem('expanded-folders', JSON.stringify([...expandedFolders]));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Expand/collapse all button with state persistence
|
||||
toggleAllBtn.addEventListener('click', function() {
|
||||
isAllExpanded = !isAllExpanded;
|
||||
const icon = toggleAllBtn.querySelector('i');
|
||||
icon.className = isAllExpanded ? 'fa fa-folder-open' : 'fa fa-folder';
|
||||
|
||||
document.querySelectorAll('.file-tree-toggle').forEach(function(toggle) {
|
||||
const path = toggle.getAttribute('data-path');
|
||||
const children = toggle.parentElement.querySelector('.file-tree-children');
|
||||
const folderIcon = toggle.querySelector('i');
|
||||
|
||||
if (children) {
|
||||
children.style.display = isAllExpanded ? 'block' : 'none';
|
||||
folderIcon.className = isAllExpanded ? 'fa fa-folder-open text-info' : 'fa fa-folder text-warning';
|
||||
|
||||
if (isAllExpanded) {
|
||||
expandedFolders.add(path);
|
||||
} else {
|
||||
expandedFolders.delete(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem('expanded-folders', JSON.stringify([...expandedFolders]));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% include '_search_modal.html' %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Create Note - Flask Blog{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input id="title" name="title" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="content" class="form-label">Content</label>
|
||||
<textarea id="content" name="content" class="form-control" rows="10"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<script>
|
||||
const easyMDE = new EasyMDE({
|
||||
element: document.getElementById('content')
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Edit {{ title }} - Flask Blog{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="content" class="form-label">Content</label>
|
||||
<textarea id="content" name="content" class="form-control" rows="12">{{ content }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<a href="{{ url_for('note', note_path=current_note) }}" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<script>
|
||||
const easyMDE = new EasyMDE({
|
||||
element: document.getElementById('content')
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Flask Blog{% endblock %}
|
||||
{% block content %}
|
||||
{% if notes %}
|
||||
<ul class="list-group">
|
||||
{% for note in notes %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('note', slug=note.slug) }}">{{ note.title }}</a>
|
||||
<span>
|
||||
<a href="{{ url_for('edit_note', slug=note.slug) }}" class="btn btn-sm btn-outline-secondary">Edit</a>
|
||||
<form action="{{ url_for('delete_note', slug=note.slug) }}" method="post" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Delete this note?');">Delete</button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No notes found.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ title }} - Flask Blog{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-3">
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="mb-4">
|
||||
<a href="{{ url_for('edit_note', note_path=current_note) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||
<form action="{{ url_for('delete_note', note_path=current_note) }}" method="post" style="display:inline;">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Delete this note?');">Delete</button>
|
||||
</form>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-link btn-sm">Back to Notes</a>
|
||||
</div>
|
||||
<div class="card card-body">
|
||||
{{ html_content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user