batman
This commit is contained in:
commit
345a801c40
33 changed files with 5499 additions and 0 deletions
85
README.md
Normal file
85
README.md
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
# Vim Docs - Markdown Documentation Platform
|
||||||
|
|
||||||
|
A Flask-based documentation platform with Vim editing capabilities, designed for developers and tech enthusiasts who prefer keyboard-centric navigation and editing.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Vim Editing**: Full Vim keybindings for editing your documents
|
||||||
|
- **Markdown Support**: GitHub-style markdown rendering with support for tables, code blocks, and alert blocks
|
||||||
|
- **Real-time Preview**: Split-screen editing with synchronized scrolling between editor and preview
|
||||||
|
- **Organization**: Hierarchical categories with customizable icons
|
||||||
|
- **Tags**: Tag-based document organization and filtering
|
||||||
|
- **Export**: Export your documents as markdown files
|
||||||
|
- **Keyboard Shortcuts**: Navigate the app efficiently with Vim-inspired keyboard shortcuts
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- pip (Python package manager)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone this repository:
|
||||||
|
```
|
||||||
|
git clone https://github.com/yourusername/vim-docs.git
|
||||||
|
cd vim-docs
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create and activate a virtual environment (recommended):
|
||||||
|
```
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Initialize the database:
|
||||||
|
```
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Access the application:
|
||||||
|
Open your browser and navigate to `http://localhost:5000`
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
- `Ctrl+E`: Edit the current document (when viewing)
|
||||||
|
- `n`: Create a new document
|
||||||
|
- `/`: Focus the search box (if available)
|
||||||
|
- `g h`: Go to the home page
|
||||||
|
|
||||||
|
## Within the Editor
|
||||||
|
|
||||||
|
- Standard Vim keybindings (`h`, `j`, `k`, `l` for navigation, etc.)
|
||||||
|
- `Ctrl+S`: Save the current document
|
||||||
|
|
||||||
|
## Alert Blocks
|
||||||
|
|
||||||
|
You can create GitHub-style alert blocks in your markdown:
|
||||||
|
|
||||||
|
```
|
||||||
|
> [!INFO]
|
||||||
|
> This is an information alert.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This is a warning alert.
|
||||||
|
|
||||||
|
> [!DANGER]
|
||||||
|
> This is a danger alert.
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- CodeMirror for the Vim editor implementation
|
||||||
|
- Marked.js for Markdown parsing
|
||||||
|
- Material Design Icons for beautiful iconography
|
||||||
|
- CascadyaCove Nerd Font for editor typography
|
4
app.py
Normal file
4
app.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True)
|
56
app/__init__.py
Normal file
56
app/__init__.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
# App package initialization
|
||||||
|
from flask import Flask
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Initialize SQLAlchemy outside of create_app
|
||||||
|
db = SQLAlchemy()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
migrate = Migrate()
|
||||||
|
|
||||||
|
# App configuration
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(24)
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///docs.db'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
PERMANENT_SESSION_LIFETIME = timedelta(hours=12)
|
||||||
|
SESSION_TYPE = 'filesystem'
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db.init_app(app)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
|
||||||
|
# Configure login manager
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
login_manager.login_message = 'Please log in to access this page.'
|
||||||
|
login_manager.login_message_category = 'info'
|
||||||
|
|
||||||
|
# Configure session
|
||||||
|
app.config['SESSION_PERMANENT'] = True
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
from app.routes import main as main_bp
|
||||||
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
|
from app.auth import bp as auth_bp
|
||||||
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
# Create app instance
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# Import models after db initialization to avoid circular imports
|
||||||
|
from app.models.document import Document, Category, Tag
|
||||||
|
from app.models.user import User
|
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/routes.cpython-313.pyc
Normal file
BIN
app/__pycache__/routes.cpython-313.pyc
Normal file
Binary file not shown.
5
app/auth/__init__.py
Normal file
5
app/auth/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
from app.auth import routes
|
BIN
app/auth/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/auth/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/auth/__pycache__/forms.cpython-313.pyc
Normal file
BIN
app/auth/__pycache__/forms.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/auth/__pycache__/routes.cpython-313.pyc
Normal file
BIN
app/auth/__pycache__/routes.cpython-313.pyc
Normal file
Binary file not shown.
21
app/auth/forms.py
Normal file
21
app/auth/forms.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||||
|
from wtforms.validators import DataRequired, Length, EqualTo, ValidationError
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
username = StringField('Username', validators=[DataRequired()])
|
||||||
|
password = PasswordField('Password', validators=[DataRequired()])
|
||||||
|
remember_me = BooleanField('Remember Me')
|
||||||
|
submit = SubmitField('Sign In')
|
||||||
|
|
||||||
|
class SignupForm(FlaskForm):
|
||||||
|
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=64)])
|
||||||
|
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
|
||||||
|
password2 = PasswordField('Repeat Password', validators=[DataRequired(), EqualTo('password')])
|
||||||
|
submit = SubmitField('Register')
|
||||||
|
|
||||||
|
def validate_username(self, username):
|
||||||
|
user = User.query.filter_by(username=username.data).first()
|
||||||
|
if user is not None:
|
||||||
|
raise ValidationError('Please use a different username.')
|
77
app/auth/routes.py
Normal file
77
app/auth/routes.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
from flask import render_template, request, redirect, url_for, flash, jsonify, session
|
||||||
|
from flask_login import login_user, logout_user, current_user, login_required
|
||||||
|
from app import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.auth import bp
|
||||||
|
from app.auth.forms import LoginForm, SignupForm
|
||||||
|
from app.models.document import Category
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('main.index'))
|
||||||
|
|
||||||
|
form = LoginForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User.query.filter_by(username=form.username.data).first()
|
||||||
|
if user is None or not user.check_password(form.password.data):
|
||||||
|
flash('Invalid username or password', 'error')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
login_user(user, remember=form.remember_me.data)
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
if not next_page or urlparse(next_page).netloc != '':
|
||||||
|
next_page = url_for('main.index')
|
||||||
|
|
||||||
|
return redirect(next_page)
|
||||||
|
|
||||||
|
return render_template('auth/login.html', title='Sign In', form=form)
|
||||||
|
|
||||||
|
@bp.route('/signup', methods=['GET', 'POST'])
|
||||||
|
def signup():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('main.index'))
|
||||||
|
|
||||||
|
form = SignupForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User(username=form.username.data)
|
||||||
|
user.set_password(form.password.data)
|
||||||
|
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.flush() # Get the user ID for the next step
|
||||||
|
|
||||||
|
# Create root category for the user
|
||||||
|
root_category = Category(
|
||||||
|
name='My Documents',
|
||||||
|
icon='mdi-folder',
|
||||||
|
description='Default document category',
|
||||||
|
user_id=user.id,
|
||||||
|
is_root=True
|
||||||
|
)
|
||||||
|
db.session.add(root_category)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Your account has been created!', 'success')
|
||||||
|
login_user(user)
|
||||||
|
return redirect(url_for('main.index'))
|
||||||
|
|
||||||
|
return render_template('auth/signup.html', title='Sign Up', form=form)
|
||||||
|
|
||||||
|
@bp.route('/logout')
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
@bp.route('/settings', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def settings():
|
||||||
|
if request.method == 'POST':
|
||||||
|
if 'theme_color' in request.form:
|
||||||
|
current_user.theme_color = request.form['theme_color']
|
||||||
|
db.session.commit()
|
||||||
|
flash('Settings updated!', 'success')
|
||||||
|
|
||||||
|
return render_template('auth/settings.html', title='Settings')
|
1
app/models/__init__.py
Normal file
1
app/models/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Models package initialization
|
BIN
app/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/document.cpython-313.pyc
Normal file
BIN
app/models/__pycache__/document.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/user.cpython-313.pyc
Normal file
BIN
app/models/__pycache__/user.cpython-313.pyc
Normal file
Binary file not shown.
84
app/models/document.py
Normal file
84
app/models/document.py
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import url_for
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Association table for document-tag many-to-many relationship
|
||||||
|
document_tags = db.Table('document_tags',
|
||||||
|
db.Column('document_id', db.Integer, db.ForeignKey('document.id'), primary_key=True),
|
||||||
|
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Document(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
content = db.Column(db.Text, nullable=False, default='')
|
||||||
|
created_date = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_date = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
tags = db.relationship('Tag', secondary=document_tags, backref=db.backref('documents', lazy='dynamic'))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Document {self.title}>'
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'title': self.title,
|
||||||
|
'content': self.content,
|
||||||
|
'created_date': self.created_date.isoformat(),
|
||||||
|
'updated_date': self.updated_date.isoformat(),
|
||||||
|
'category_id': self.category_id,
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'tags': [tag.name for tag in self.tags]
|
||||||
|
}
|
||||||
|
|
||||||
|
class Category(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
icon = db.Column(db.String(100), default='mdi-folder-outline') # Material Design Icons
|
||||||
|
description = db.Column(db.String(200))
|
||||||
|
parent_id = db.Column(db.Integer, db.ForeignKey('category.id'))
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
is_root = db.Column(db.Boolean, default=False)
|
||||||
|
documents = db.relationship('Document', backref='category', lazy='dynamic')
|
||||||
|
children = db.relationship('Category', backref=db.backref('parent', remote_side=[id]), lazy='dynamic')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Category {self.name}>'
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'icon': self.icon,
|
||||||
|
'description': self.description,
|
||||||
|
'parent_id': self.parent_id,
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'is_root': self.is_root,
|
||||||
|
'children': [child.to_dict() for child in self.children],
|
||||||
|
'documents': [doc.id for doc in self.documents]
|
||||||
|
}
|
||||||
|
|
||||||
|
class Tag(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(50), nullable=False)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
color = db.Column(db.String(20), default='#50fa7b')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('name', 'user_id', name='_tag_user_uc'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Tag {self.name}>'
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'color': self.color,
|
||||||
|
'document_count': self.documents.count()
|
||||||
|
}
|
34
app/models/user.py
Normal file
34
app/models/user.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
from app import db, login_manager
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(64), index=True, unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(128), nullable=False)
|
||||||
|
created_date = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
theme_color = db.Column(db.String(20), default='#50fa7b') # Default green color
|
||||||
|
documents = db.relationship('Document', backref='author', lazy='dynamic')
|
||||||
|
categories = db.relationship('Category', backref='owner', lazy='dynamic')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.username}>'
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'username': self.username,
|
||||||
|
'created_date': self.created_date.isoformat(),
|
||||||
|
'theme_color': self.theme_color
|
||||||
|
}
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(id):
|
||||||
|
return User.query.get(int(id))
|
268
app/routes.py
Normal file
268
app/routes.py
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from app import db
|
||||||
|
from app.models.document import Document, Category, Tag
|
||||||
|
from app.models.user import User
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import io
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
main = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
@main.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
"""Home page showing categories and recent documents"""
|
||||||
|
root_categories = Category.query.filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
parent_id=None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
recent_docs = Document.query.filter_by(
|
||||||
|
user_id=current_user.id
|
||||||
|
).order_by(Document.updated_date.desc()).limit(5).all()
|
||||||
|
|
||||||
|
return render_template('index.html', categories=root_categories, recent_docs=recent_docs)
|
||||||
|
|
||||||
|
@main.route('/category/<int:category_id>')
|
||||||
|
@login_required
|
||||||
|
def view_category(category_id):
|
||||||
|
"""View a specific category and its documents"""
|
||||||
|
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
|
||||||
|
return render_template('category.html', category=category)
|
||||||
|
|
||||||
|
@main.route('/document/<int:doc_id>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def view_document(doc_id):
|
||||||
|
"""View a document in read mode"""
|
||||||
|
document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()
|
||||||
|
return render_template('document_view.html', document=document)
|
||||||
|
|
||||||
|
@main.route('/document/<int:doc_id>/edit', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def edit_document(doc_id):
|
||||||
|
"""Edit a document with the Vim editor"""
|
||||||
|
document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()
|
||||||
|
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||||
|
tags = Tag.query.filter_by(user_id=current_user.id).all()
|
||||||
|
return render_template('document_edit.html', document=document, categories=categories, tags=tags)
|
||||||
|
|
||||||
|
@main.route('/document/new', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def new_document():
|
||||||
|
"""Create a new document with the Vim editor"""
|
||||||
|
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||||
|
tags = Tag.query.filter_by(user_id=current_user.id).all()
|
||||||
|
category_id = request.args.get('category')
|
||||||
|
|
||||||
|
document = None
|
||||||
|
|
||||||
|
if category_id:
|
||||||
|
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
|
||||||
|
if category:
|
||||||
|
document = Document(
|
||||||
|
title="Untitled Document",
|
||||||
|
content="",
|
||||||
|
category_id=category_id,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template('document_edit.html', document=document, categories=categories, tags=tags, preselected_category_id=category_id)
|
||||||
|
|
||||||
|
@main.route('/api/document', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def save_document():
|
||||||
|
"""Save a document (new or existing)"""
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
# Get root category as default
|
||||||
|
root_category = Category.query.filter_by(user_id=current_user.id, is_root=True).first()
|
||||||
|
default_category_id = root_category.id if root_category else None
|
||||||
|
|
||||||
|
if 'id' in data and data['id']:
|
||||||
|
# Update existing document - verify ownership
|
||||||
|
document = Document.query.filter_by(id=data['id'], user_id=current_user.id).first_or_404()
|
||||||
|
document.title = data['title']
|
||||||
|
document.content = data['content']
|
||||||
|
document.category_id = data['category_id'] if data['category_id'] else default_category_id
|
||||||
|
else:
|
||||||
|
# Create new document
|
||||||
|
document = Document(
|
||||||
|
title=data['title'],
|
||||||
|
content=data['content'],
|
||||||
|
category_id=data['category_id'] if data['category_id'] else default_category_id,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
db.session.add(document)
|
||||||
|
|
||||||
|
# Handle tags
|
||||||
|
if 'tags' in data:
|
||||||
|
document.tags = []
|
||||||
|
for tag_name in data['tags']:
|
||||||
|
tag = Tag.query.filter_by(name=tag_name, user_id=current_user.id).first()
|
||||||
|
if not tag:
|
||||||
|
tag = Tag(name=tag_name, user_id=current_user.id)
|
||||||
|
db.session.add(tag)
|
||||||
|
document.tags.append(tag)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(document.to_dict())
|
||||||
|
|
||||||
|
@main.route('/api/document/<int:doc_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def delete_document(doc_id):
|
||||||
|
"""Delete a document"""
|
||||||
|
document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()
|
||||||
|
db.session.delete(document)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@main.route('/api/category', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def save_category():
|
||||||
|
"""Save a category (new or existing)"""
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
if 'id' in data and data['id']:
|
||||||
|
# Update existing category - verify ownership
|
||||||
|
category = Category.query.filter_by(id=data['id'], user_id=current_user.id).first_or_404()
|
||||||
|
category.name = data['name']
|
||||||
|
category.icon = data['icon']
|
||||||
|
category.description = data.get('description', '')
|
||||||
|
|
||||||
|
# Only change parent if it belongs to the same user
|
||||||
|
if data.get('parent_id'):
|
||||||
|
parent = Category.query.filter_by(id=data['parent_id'], user_id=current_user.id).first()
|
||||||
|
if parent:
|
||||||
|
category.parent_id = parent.id
|
||||||
|
else:
|
||||||
|
# Create new category
|
||||||
|
category = Category(
|
||||||
|
name=data['name'],
|
||||||
|
icon=data['icon'],
|
||||||
|
description=data.get('description', ''),
|
||||||
|
parent_id=data['parent_id'] if data.get('parent_id') else None,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
db.session.add(category)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(category.to_dict())
|
||||||
|
|
||||||
|
@main.route('/api/category/<int:category_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def delete_category(category_id):
|
||||||
|
"""Delete a category and optionally reassign documents"""
|
||||||
|
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
|
||||||
|
|
||||||
|
# Can't delete root category
|
||||||
|
if category.is_root:
|
||||||
|
return jsonify({'error': 'Cannot delete root category'}), 400
|
||||||
|
|
||||||
|
# Get target category for documents if specified
|
||||||
|
new_category_id = request.args.get('new_category_id')
|
||||||
|
if new_category_id:
|
||||||
|
new_category = Category.query.filter_by(id=new_category_id, user_id=current_user.id).first()
|
||||||
|
if new_category:
|
||||||
|
# Reassign documents
|
||||||
|
for doc in category.documents:
|
||||||
|
doc.category_id = new_category.id
|
||||||
|
else:
|
||||||
|
# Move documents to no category
|
||||||
|
for doc in category.documents:
|
||||||
|
doc.category_id = None
|
||||||
|
|
||||||
|
# Also handle child categories
|
||||||
|
for child in category.children:
|
||||||
|
if new_category_id:
|
||||||
|
child.parent_id = new_category_id
|
||||||
|
else:
|
||||||
|
child.parent_id = None
|
||||||
|
|
||||||
|
db.session.delete(category)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@main.route('/api/search', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def search_documents():
|
||||||
|
"""Search for documents by title, content, or tags"""
|
||||||
|
query = request.args.get('q', '')
|
||||||
|
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
return jsonify({'results': []})
|
||||||
|
|
||||||
|
# Search in title, content, and tags for current user's documents only
|
||||||
|
docs = Document.query.filter(
|
||||||
|
Document.user_id == current_user.id,
|
||||||
|
or_(
|
||||||
|
Document.title.ilike(f'%{query}%'),
|
||||||
|
Document.content.ilike(f'%{query}%'),
|
||||||
|
Document.tags.any(Tag.name.ilike(f'%{query}%'))
|
||||||
|
)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for doc in docs:
|
||||||
|
# Find match in content
|
||||||
|
match = None
|
||||||
|
if query.lower() in doc.content.lower():
|
||||||
|
# Find the sentence containing the match
|
||||||
|
content_lower = doc.content.lower()
|
||||||
|
query_pos = content_lower.find(query.lower())
|
||||||
|
|
||||||
|
# Get a snippet around the match
|
||||||
|
start = max(0, content_lower.rfind('.', 0, query_pos) + 1)
|
||||||
|
end = content_lower.find('.', query_pos)
|
||||||
|
if end == -1:
|
||||||
|
end = min(len(doc.content), query_pos + 200)
|
||||||
|
|
||||||
|
match = doc.content[start:end].strip()
|
||||||
|
|
||||||
|
# Get category name
|
||||||
|
category_name = doc.category.name if doc.category else None
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'id': doc.id,
|
||||||
|
'title': doc.title,
|
||||||
|
'category': category_name,
|
||||||
|
'tags': [tag.name for tag in doc.tags],
|
||||||
|
'match': match
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'results': results})
|
||||||
|
|
||||||
|
@main.route('/api/tags', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_tags():
|
||||||
|
"""Get all tags for the current user"""
|
||||||
|
tags = Tag.query.filter_by(user_id=current_user.id).all()
|
||||||
|
return jsonify({'tags': [tag.to_dict() for tag in tags]})
|
||||||
|
|
||||||
|
@main.route('/document/<int:doc_id>/export', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def export_document(doc_id):
|
||||||
|
"""Export a document as a markdown file"""
|
||||||
|
document = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()
|
||||||
|
|
||||||
|
# Create a file-like object in memory
|
||||||
|
file_data = io.BytesIO(document.content.encode('utf-8'))
|
||||||
|
file_data.seek(0)
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
file_data,
|
||||||
|
mimetype='text/markdown',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"{document.title.replace(' ', '_')}.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
@main.route('/api/categories', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_categories():
|
||||||
|
"""Get all root categories with their children for the current user"""
|
||||||
|
root_categories = Category.query.filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
parent_id=None
|
||||||
|
).all()
|
||||||
|
return jsonify([category.to_dict() for category in root_categories])
|
691
app/static/css/style.css
Normal file
691
app/static/css/style.css
Normal file
|
@ -0,0 +1,691 @@
|
||||||
|
/* Main CSS Variables */
|
||||||
|
:root {
|
||||||
|
--bg-color: #1a1a1a;
|
||||||
|
--bg-light: #2a2a2a;
|
||||||
|
--text-color: #f8f8f2;
|
||||||
|
--text-muted: #a0a0a0;
|
||||||
|
--border-color: #3a3a3a;
|
||||||
|
--accent-color: #8be9fd;
|
||||||
|
--primary-color: #50fa7b;
|
||||||
|
--danger-color: #ff5555;
|
||||||
|
--warning-color: #ffb86c;
|
||||||
|
--info-color: #8be9fd;
|
||||||
|
--caution-color: #f1fa8c;
|
||||||
|
--note-color: #bd93f9;
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--header-height: 64px;
|
||||||
|
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
line-height: 1.6;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App layout */
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h1 i {
|
||||||
|
margin-right: 10px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav li {
|
||||||
|
padding: 8px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav li a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav li a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
background-color: rgba(80, 250, 123, 0.1);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav li a.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
background-color: rgba(80, 250, 123, 0.15);
|
||||||
|
border-left: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav li a i {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section > span {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section > span i {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tree {
|
||||||
|
margin-left: 15px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 1px dashed var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
height: var(--header-height);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left, .header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h2 {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subcategories section styling */
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category grid */
|
||||||
|
.category-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
padding: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-card a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-decoration: none;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-card i {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-meta {
|
||||||
|
margin-top: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-style: dashed;
|
||||||
|
height: 100%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-card:hover {
|
||||||
|
background-color: rgba(80, 250, 123, 0.1);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-card i {
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-card:hover i {
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-card h4 {
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-card:hover h4 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current Category Indicator */
|
||||||
|
.current-category {
|
||||||
|
background-color: rgba(80, 250, 123, 0.1);
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin: -30px -30px 20px -30px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-category h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-category h3 i {
|
||||||
|
margin-right: 10px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-category .category-path {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-category .category-path a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-category .category-path a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document list */
|
||||||
|
.document-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button i {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--bg-color);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary:hover {
|
||||||
|
background-color: rgba(80, 250, 123, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dashboard {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 i {
|
||||||
|
margin-right: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document card */
|
||||||
|
.document-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-info h4 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-info h4 a {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty states */
|
||||||
|
.empty-state {
|
||||||
|
padding: 60px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 40px auto;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form elements */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preview {
|
||||||
|
margin-left: 15px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag styles */
|
||||||
|
.document-tag.small {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document view styles */
|
||||||
|
.document-view {
|
||||||
|
padding: 30px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor specific styles */
|
||||||
|
.editor-container {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 130px); /* Account for header + document info */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane, .preview-pane {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
padding: 0;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown preview enhancements */
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote {
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
color: #555;
|
||||||
|
border-left: 0.25em solid #dfe2e5;
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote[data-type="info"],
|
||||||
|
.markdown-body blockquote[data-type="note"] {
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
border-left-color: var(--info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote[data-type="warning"] {
|
||||||
|
background-color: #fef9e7;
|
||||||
|
border-left-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote[data-type="danger"],
|
||||||
|
.markdown-body blockquote[data-type="important"] {
|
||||||
|
background-color: #fff0f0;
|
||||||
|
border-left-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote[data-type="caution"] {
|
||||||
|
background-color: #fdfae5;
|
||||||
|
border-left-color: var(--caution-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote[data-type="tip"] {
|
||||||
|
background-color: #effaf5;
|
||||||
|
border-left-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body a {
|
||||||
|
color: #0366d6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body table th,
|
||||||
|
.markdown-body table td {
|
||||||
|
border: 1px solid #dfe2e5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body table th {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
1817
app/static/fonts/CascadiaCove.ttf
Normal file
1817
app/static/fonts/CascadiaCove.ttf
Normal file
File diff suppressed because one or more lines are too long
121
app/static/js/app.js
Normal file
121
app/static/js/app.js
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Sidebar toggle
|
||||||
|
const sidebarToggle = document.getElementById('sidebar-toggle');
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
|
||||||
|
if (sidebarToggle && sidebar) {
|
||||||
|
sidebarToggle.addEventListener('click', function() {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load category tree
|
||||||
|
loadCategoryTree();
|
||||||
|
|
||||||
|
// Initialize keyboard shortcuts
|
||||||
|
initKeyboardShortcuts();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load and render category tree in the sidebar
|
||||||
|
function loadCategoryTree() {
|
||||||
|
const categoryTree = document.getElementById('category-tree');
|
||||||
|
|
||||||
|
if (!categoryTree) return;
|
||||||
|
|
||||||
|
fetch('/api/categories')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(categories => {
|
||||||
|
renderCategoryTree(categories, categoryTree);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading categories:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively render category tree
|
||||||
|
function renderCategoryTree(categories, container) {
|
||||||
|
if (!categories || categories.length === 0) return;
|
||||||
|
|
||||||
|
categories.forEach(category => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `/category/${category.id}`;
|
||||||
|
link.innerHTML = `
|
||||||
|
<i class="mdi ${category.icon}"></i>
|
||||||
|
${category.name}
|
||||||
|
`;
|
||||||
|
|
||||||
|
li.appendChild(link);
|
||||||
|
|
||||||
|
// If this category has children, add a nested ul
|
||||||
|
if (category.children && category.children.length > 0) {
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
ul.className = 'category-tree';
|
||||||
|
renderCategoryTree(category.children, ul);
|
||||||
|
li.appendChild(ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add keyboard shortcuts
|
||||||
|
function initKeyboardShortcuts() {
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
// Only process if not in an input element
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+E to edit current document
|
||||||
|
if (e.ctrlKey && e.key === 'e') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Check if on document view page
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path.match(/^\/document\/\d+$/)) {
|
||||||
|
// Extract document ID and redirect to edit page
|
||||||
|
const docId = path.split('/').pop();
|
||||||
|
window.location.href = `/document/${docId}/edit`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'n' to create new document
|
||||||
|
if (e.key === 'n' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = '/document/new';
|
||||||
|
}
|
||||||
|
|
||||||
|
// '/' to focus search (if we had one)
|
||||||
|
if (e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'g h' sequence for home
|
||||||
|
if (e.key === 'g') {
|
||||||
|
const keySequence = function(e) {
|
||||||
|
if (e.key === 'h') {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
document.removeEventListener('keydown', keySequence);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', keySequence);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch root categories for API
|
||||||
|
function fetchCategories() {
|
||||||
|
return fetch('/api/categories')
|
||||||
|
.then(response => response.json())
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching categories:', error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
72
app/templates/auth/login.html
Normal file
72
app/templates/auth/login.html
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Login - Vim Docs{% endblock %}
|
||||||
|
|
||||||
|
{% block auth_content %}
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-gray-900">
|
||||||
|
<div class="w-full max-w-md p-8 space-y-8 bg-gray-800 rounded-lg shadow-lg">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-white">
|
||||||
|
<i class="mdi mdi-vim text-primary text-4xl mr-2"></i> Vim Docs
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-400">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-8 space-y-6" method="POST" action="{{ url_for('auth.login') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="p-4 {{ 'bg-red-600/20 text-red-400' if category == 'error' else 'bg-green-600/20 text-green-400' }} rounded-md">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-400">Username</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
{{ form.username(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary") }}
|
||||||
|
</div>
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
{{ form.password(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary") }}
|
||||||
|
</div>
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
{{ form.remember_me(class="h-4 w-4 text-primary focus:ring-primary border-gray-600 rounded bg-gray-700") }}
|
||||||
|
<label for="remember_me" class="ml-2 block text-sm text-gray-400">
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form.submit(class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-black bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Don't have an account?
|
||||||
|
<a href="{{ url_for('auth.signup') }}" class="font-medium text-primary hover:text-primary-light">
|
||||||
|
Sign up
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
89
app/templates/auth/settings.html
Normal file
89
app/templates/auth/settings.html
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Settings - Vim Docs{% endblock %}
|
||||||
|
|
||||||
|
{% block header_title %}Settings{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto p-6">
|
||||||
|
<div class="bg-gray-800 shadow-md rounded-lg overflow-hidden">
|
||||||
|
<div class="border-b border-gray-700 p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-2">User Settings</h2>
|
||||||
|
<p class="text-gray-400">Customize your Vim Docs experience</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="p-4 m-4 {{ 'bg-red-600/20 text-red-400' if category == 'error' else 'bg-green-600/20 text-green-400' }} rounded-md">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.settings') }}" class="p-6 space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-white mb-4">Theme Settings</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||||
|
Primary Color
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="color" id="theme_color" name="theme_color"
|
||||||
|
value="{{ current_user.theme_color }}"
|
||||||
|
class="h-10 w-20 bg-transparent rounded border border-gray-600">
|
||||||
|
<span class="ml-3 text-gray-400 text-sm">{{ current_user.theme_color }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<p class="text-sm text-gray-400 mb-2">Color Presets</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" data-color="#50fa7b" class="preset-color w-8 h-8 rounded-full bg-[#50fa7b]"></button>
|
||||||
|
<button type="button" data-color="#bd93f9" class="preset-color w-8 h-8 rounded-full bg-[#bd93f9]"></button>
|
||||||
|
<button type="button" data-color="#ff79c6" class="preset-color w-8 h-8 rounded-full bg-[#ff79c6]"></button>
|
||||||
|
<button type="button" data-color="#f1fa8c" class="preset-color w-8 h-8 rounded-full bg-[#f1fa8c]"></button>
|
||||||
|
<button type="button" data-color="#8be9fd" class="preset-color w-8 h-8 rounded-full bg-[#8be9fd]"></button>
|
||||||
|
<button type="button" data-color="#ffb86c" class="preset-color w-8 h-8 rounded-full bg-[#ffb86c]"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-700">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const colorInput = document.getElementById('theme_color');
|
||||||
|
const presetButtons = document.querySelectorAll('.preset-color');
|
||||||
|
|
||||||
|
presetButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const color = this.getAttribute('data-color');
|
||||||
|
colorInput.value = color;
|
||||||
|
|
||||||
|
// Apply selected style to the button
|
||||||
|
presetButtons.forEach(btn => btn.classList.remove('ring-2', 'ring-white'));
|
||||||
|
this.classList.add('ring-2', 'ring-white');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply selected style to the current color on load
|
||||||
|
if (button.getAttribute('data-color') === colorInput.value) {
|
||||||
|
button.classList.add('ring-2', 'ring-white');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
73
app/templates/auth/signup.html
Normal file
73
app/templates/auth/signup.html
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sign Up - Vim Docs{% endblock %}
|
||||||
|
|
||||||
|
{% block auth_content %}
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-gray-900">
|
||||||
|
<div class="w-full max-w-md p-8 space-y-8 bg-gray-800 rounded-lg shadow-lg">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-white">
|
||||||
|
<i class="mdi mdi-vim text-primary text-4xl mr-2"></i> Vim Docs
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-400">Create your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-8 space-y-6" method="POST" action="{{ url_for('auth.signup') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="p-4 {{ 'bg-red-600/20 text-red-400' if category == 'error' else 'bg-green-600/20 text-green-400' }} rounded-md">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-400">Username</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
{{ form.username(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary") }}
|
||||||
|
</div>
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
{{ form.password(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary", placeholder="Minimum 8 characters") }}
|
||||||
|
</div>
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password2" class="block text-sm font-medium text-gray-400">Confirm Password</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
{{ form.password2(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary") }}
|
||||||
|
</div>
|
||||||
|
{% for error in form.password2.errors %}
|
||||||
|
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form.submit(class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-black bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Already have an account?
|
||||||
|
<a href="{{ url_for('auth.login') }}" class="font-medium text-primary hover:text-primary-light">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
332
app/templates/base.html
Normal file
332
app/templates/base.html
Normal file
|
@ -0,0 +1,332 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Vim Docs{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Material Design Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css">
|
||||||
|
|
||||||
|
<!-- CascadyaCove Nerd Font -->
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'CascadyaCove NF';
|
||||||
|
src: url('/static/fonts/CascadiaCove.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {% if current_user.is_authenticated %}'{{ current_user.theme_color }}'{% else %}'#50fa7b'{% endif %},
|
||||||
|
'primary-dark': {% if current_user.is_authenticated %}'{{ current_user.theme_color }}'{% else %}'#39bd5e'{% endif %},
|
||||||
|
'primary-light': {% if current_user.is_authenticated %}'{{ current_user.theme_color }}'{% else %}'#7dfb96'{% endif %},
|
||||||
|
'gray': {
|
||||||
|
800: '#282a36',
|
||||||
|
900: '#1e1f29',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: ['CascadyaCove NF', 'monospace']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-gray-300 min-h-screen font-sans">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="flex min-h-screen {% if request.cookies.get('sidebar_collapsed') == 'true' %}sidebar-hidden{% endif %}">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-64 bg-gray-800 h-screen flex-shrink-0 fixed left-0 top-0 z-10 overflow-y-auto transition-all ease-in-out duration-300">
|
||||||
|
<div class="p-4 border-b border-gray-700 flex items-center">
|
||||||
|
<h1 class="text-xl font-semibold text-white flex items-center">
|
||||||
|
<i class="mdi mdi-vim text-primary text-2xl mr-2"></i> Vim Docs
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="py-4">
|
||||||
|
<ul class="list-none">
|
||||||
|
<li class="my-1">
|
||||||
|
<a href="{{ url_for('main.index') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-primary hover:bg-gray-700 rounded-md transition-all {{ 'bg-primary/10 text-primary' if request.endpoint == 'main.index' }}">
|
||||||
|
<i class="mdi mdi-home mr-3"></i> Home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="my-4">
|
||||||
|
<div class="block font-medium text-gray-400 px-4 py-2 flex items-center">
|
||||||
|
<i class="mdi mdi-folder-multiple-outline mr-3"></i> Categories
|
||||||
|
</div>
|
||||||
|
<ul class="ml-3 pl-3 border-l border-gray-700 my-1" id="category-tree">
|
||||||
|
<!-- Categories will be loaded here via JS -->
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="my-4">
|
||||||
|
<div class="block font-medium text-gray-400 px-4 py-2 flex items-center">
|
||||||
|
<i class="mdi mdi-file-document-multiple-outline mr-3"></i> Documents
|
||||||
|
</div>
|
||||||
|
<ul class="ml-3 pl-3 border-l border-gray-700 my-1">
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('main.new_document') }}" class="flex items-center py-1 px-2 text-gray-400 hover:text-primary rounded transition-colors {{ 'text-primary' if request.endpoint == 'main.new_document' }}">
|
||||||
|
<i class="mdi mdi-plus-circle-outline mr-2 text-sm"></i> New Document
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('main.index') }}?view=recent" class="flex items-center py-1 px-2 text-gray-400 hover:text-primary rounded transition-colors">
|
||||||
|
<i class="mdi mdi-clock-outline mr-2 text-sm"></i> Recent Documents
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="my-1">
|
||||||
|
<a href="{{ url_for('main.new_document') }}" class="flex items-center px-4 py-2 bg-primary text-black hover:bg-primary-dark rounded-md transition-all">
|
||||||
|
<i class="mdi mdi-plus-circle mr-3"></i> New Document
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 flex flex-col ml-64 transition-all ease-in-out duration-300">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gray-800 border-b border-gray-700 p-4 flex justify-between items-center sticky top-0 z-10">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button id="toggle-sidebar" class="p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors mr-3">
|
||||||
|
<i class="mdi mdi-menu"></i>
|
||||||
|
</button>
|
||||||
|
<h2 class="text-white text-lg font-medium">{% block header_title %}Dashboard{% endblock %}</h2>
|
||||||
|
|
||||||
|
<div class="relative ml-4">
|
||||||
|
<div class="flex items-center h-9 rounded-md border border-gray-700 bg-gray-900">
|
||||||
|
<i class="mdi mdi-magnify text-gray-400 mx-3"></i>
|
||||||
|
<input type="text" id="search-input"
|
||||||
|
class="bg-transparent border-0 outline-none text-white w-64"
|
||||||
|
placeholder="Search documents...">
|
||||||
|
</div>
|
||||||
|
<div id="search-results" class="absolute left-0 right-0 top-full mt-1 bg-gray-800 rounded-md shadow-lg z-20 max-h-96 overflow-y-auto hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{% block header_actions %}{% endblock %}
|
||||||
|
|
||||||
|
<div class="relative ml-2">
|
||||||
|
<button id="user-menu-button" class="p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors flex items-center">
|
||||||
|
<span class="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary mr-2">
|
||||||
|
{{ current_user.username[0].upper() }}
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline mr-1">{{ current_user.username }}</span>
|
||||||
|
<i class="mdi mdi-chevron-down text-sm"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="user-dropdown" class="absolute right-0 mt-2 py-2 w-48 bg-gray-800 rounded-md shadow-lg z-20 hidden">
|
||||||
|
<a href="{{ url_for('auth.settings') }}" class="{{ 'bg-gray-700' if request.endpoint == 'auth.settings' }} block px-4 py-2 text-gray-300 hover:bg-gray-700 hover:text-white w-full text-left">
|
||||||
|
<i class="mdi mdi-cog-outline mr-2"></i> Settings
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('auth.logout') }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-700 hover:text-white w-full text-left">
|
||||||
|
<i class="mdi mdi-logout-variant mr-2"></i> Sign Out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
<div class="flex-1 p-6 overflow-auto">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Toggle sidebar
|
||||||
|
const toggleBtn = document.getElementById('toggle-sidebar');
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.addEventListener('click', function() {
|
||||||
|
document.querySelector('body > div').classList.toggle('sidebar-hidden');
|
||||||
|
|
||||||
|
// Save preference in cookie
|
||||||
|
const isCollapsed = document.querySelector('body > div').classList.contains('sidebar-hidden');
|
||||||
|
document.cookie = `sidebar_collapsed=${isCollapsed}; path=/; max-age=31536000`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// User dropdown
|
||||||
|
const userMenuBtn = document.getElementById('user-menu-button');
|
||||||
|
const userDropdown = document.getElementById('user-dropdown');
|
||||||
|
|
||||||
|
if (userMenuBtn && userDropdown) {
|
||||||
|
userMenuBtn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
userDropdown.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!userMenuBtn.contains(e.target) && !userDropdown.contains(e.target)) {
|
||||||
|
userDropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load categories
|
||||||
|
loadCategories();
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
setupSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadCategories() {
|
||||||
|
const categoryTree = document.getElementById('category-tree');
|
||||||
|
if (!categoryTree) return;
|
||||||
|
|
||||||
|
fetch('/api/categories')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(categories => {
|
||||||
|
categoryTree.innerHTML = ''; // Clear existing items
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
categoryTree.innerHTML = '<li class="px-4 py-2 text-gray-500">No categories found</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
categories.forEach(category => {
|
||||||
|
categoryTree.appendChild(createCategoryItem(category));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading categories:', error);
|
||||||
|
categoryTree.innerHTML = '<li class="px-4 py-2 text-red-500">Error loading categories</li>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCategoryItem(category) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/category/${category.id}`;
|
||||||
|
a.className = 'flex items-center py-1 px-2 text-gray-400 hover:text-primary rounded transition-colors';
|
||||||
|
a.innerHTML = `<i class="mdi ${category.icon} mr-2 text-sm"></i> ${category.name}`;
|
||||||
|
li.appendChild(a);
|
||||||
|
|
||||||
|
if (category.children && category.children.length > 0) {
|
||||||
|
const childrenUl = document.createElement('ul');
|
||||||
|
childrenUl.className = 'ml-2 pl-2 border-l border-gray-700 my-1';
|
||||||
|
|
||||||
|
category.children.forEach(child => {
|
||||||
|
childrenUl.appendChild(createCategoryItem(child));
|
||||||
|
});
|
||||||
|
|
||||||
|
li.appendChild(childrenUl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSearch() {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
|
||||||
|
if (!searchInput || !searchResults) return;
|
||||||
|
|
||||||
|
// Debounce function
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function() {
|
||||||
|
const context = this;
|
||||||
|
const args = arguments;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const performSearch = debounce(function() {
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
searchResults.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/search?q=${encodeURIComponent(query)}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.results.length === 0) {
|
||||||
|
searchResults.innerHTML = '<div class="p-4 text-gray-400">No results found</div>';
|
||||||
|
} else {
|
||||||
|
data.results.forEach(result => {
|
||||||
|
const item = document.createElement('a');
|
||||||
|
item.href = `/document/${result.id}`;
|
||||||
|
item.className = 'block p-3 hover:bg-gray-700 border-b border-gray-700 last:border-0';
|
||||||
|
|
||||||
|
let tagsHtml = '';
|
||||||
|
if (result.tags && result.tags.length > 0) {
|
||||||
|
tagsHtml = '<div class="flex gap-1 mt-1">' +
|
||||||
|
result.tags.map(tag => `<span class="text-xs px-2 py-1 bg-primary/20 text-primary rounded">${tag}</span>`).join('') +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchHtml = '';
|
||||||
|
if (result.match) {
|
||||||
|
const regex = new RegExp(`(${query})`, 'gi');
|
||||||
|
matchHtml = `<div class="text-sm text-gray-400 mt-1">${result.match.replace(regex, '<span class="bg-primary/30 text-primary">$1</span>')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="font-medium text-white">${result.title}</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">${result.category || 'Uncategorized'}</div>
|
||||||
|
${tagsHtml}
|
||||||
|
${matchHtml}
|
||||||
|
`;
|
||||||
|
|
||||||
|
searchResults.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
searchResults.innerHTML = '<div class="p-4 text-red-400">Error performing search</div>';
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', performSearch);
|
||||||
|
searchInput.addEventListener('focus', function() {
|
||||||
|
if (searchInput.value.trim().length >= 2) {
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
|
||||||
|
searchResults.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-gray-900 min-h-screen">
|
||||||
|
{% block auth_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
364
app/templates/category.html
Normal file
364
app/templates/category.html
Normal file
|
@ -0,0 +1,364 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ category.name }} - Vim Docs{% endblock %}
|
||||||
|
|
||||||
|
{% block header_title %}
|
||||||
|
<i class="mdi {{ category.icon }} mr-2"></i> {{ category.name }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header_actions %}
|
||||||
|
<a href="{{ url_for('main.new_document') }}?category={{ category.id }}" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="mdi mdi-plus mr-2"></i> New Document
|
||||||
|
</a>
|
||||||
|
<button id="edit-category-btn" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors ml-2">
|
||||||
|
<i class="mdi mdi-pencil mr-2"></i> Edit Category
|
||||||
|
</button>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Current category information -->
|
||||||
|
<div class="bg-gray-800 rounded-lg overflow-hidden shadow-lg p-6">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<div class="w-10 h-10 rounded-md bg-primary/20 flex items-center justify-center text-primary mr-3">
|
||||||
|
<i class="mdi {{ category.icon }} text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">{{ category.name }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
{% if category.description %}
|
||||||
|
{{ category.description }}
|
||||||
|
{% else %}
|
||||||
|
A category for organizing your documents
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex text-sm text-gray-500">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="mdi mdi-folder-outline mr-1"></i>
|
||||||
|
<a href="{{ url_for('main.index') }}" class="hover:text-primary">Home</a>
|
||||||
|
{% if category.parent %}
|
||||||
|
<span class="mx-2">/</span>
|
||||||
|
<a href="{{ url_for('main.view_category', category_id=category.parent.id) }}" class="hover:text-primary">{{ category.parent.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="mx-2">/</span>
|
||||||
|
<span class="text-gray-400">{{ category.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subcategories -->
|
||||||
|
{% if category.children.count() > 0 %}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-white">Subcategories</h2>
|
||||||
|
<button id="add-subcategory-btn" class="icon-button">
|
||||||
|
<i class="mdi mdi-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{% for subcategory in category.children %}
|
||||||
|
<div class="bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-all hover:-translate-y-1 duration-200">
|
||||||
|
<a href="{{ url_for('main.view_category', category_id=subcategory.id) }}" class="block p-5">
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<div class="w-10 h-10 rounded-md bg-primary/20 flex items-center justify-center text-primary mr-3">
|
||||||
|
<i class="mdi {{ subcategory.icon }} text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-white font-medium truncate">{{ subcategory.name }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if subcategory.description %}
|
||||||
|
<p class="text-gray-400 text-sm mb-4 line-clamp-2">{{ subcategory.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>{{ subcategory.documents.count() }} document{{ '' if subcategory.documents.count() == 1 else 's' }}</span>
|
||||||
|
<span>{{ subcategory.children.count() }} subcategor{{ 'y' if subcategory.children.count() == 1 else 'ies' }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Add subcategory card -->
|
||||||
|
<div id="add-subcategory-card" class="bg-gray-800/50 border-2 border-dashed border-gray-700 rounded-lg overflow-hidden hover:border-primary/50 hover:bg-gray-800/80 transition-all cursor-pointer">
|
||||||
|
<div class="p-5 h-full flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-gray-700/50 flex items-center justify-center mb-3">
|
||||||
|
<i class="mdi mdi-folder-plus-outline text-2xl text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-gray-400 font-medium mb-1">New Subcategory</h3>
|
||||||
|
<p class="text-gray-500 text-sm">Add a subcategory to {{ category.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Documents -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-white">Documents</h2>
|
||||||
|
<a href="{{ url_for('main.new_document') }}?category={{ category.id }}" class="icon-button" title="New Document">
|
||||||
|
<i class="mdi mdi-plus"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% if category.documents.count() > 0 %}
|
||||||
|
{% for doc in category.documents %}
|
||||||
|
<div class="bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-shadow">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<h3 class="text-white font-medium mb-2 truncate">
|
||||||
|
<a href="{{ url_for('main.view_document', doc_id=doc.id) }}" class="hover:text-primary transition-colors">
|
||||||
|
{{ doc.title }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<div class="dropdown relative ml-2">
|
||||||
|
<button class="icon-button p-1">
|
||||||
|
<i class="mdi mdi-dots-vertical"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu hidden absolute right-0 mt-2 w-40 bg-gray-700 rounded-md shadow-lg z-10">
|
||||||
|
<a href="{{ url_for('main.edit_document', doc_id=doc.id) }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-600 hover:text-white">
|
||||||
|
<i class="mdi mdi-pencil mr-2"></i> Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.export_document', doc_id=doc.id) }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-600 hover:text-white">
|
||||||
|
<i class="mdi mdi-download mr-2"></i> Export
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-gray-400 text-sm mb-3 truncate">
|
||||||
|
{{ (doc.content[:100] + '...') if doc.content|length > 100 else doc.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<i class="mdi mdi-calendar-outline mr-1"></i> {{ doc.updated_date.strftime('%b %d, %Y') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if doc.tags %}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-3">
|
||||||
|
{% for tag in doc.tags %}
|
||||||
|
<span class="text-xs px-2 py-1 bg-primary/20 text-primary rounded-full">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="col-span-full bg-gray-800/50 rounded-lg p-8 text-center">
|
||||||
|
<i class="mdi mdi-file-document-outline text-6xl text-gray-700 mb-3"></i>
|
||||||
|
<h3 class="text-lg text-gray-400 mb-3">No documents in this category</h3>
|
||||||
|
<p class="text-gray-500 mb-4">Create your first document in this category</p>
|
||||||
|
<a href="{{ url_for('main.new_document') }}?category={{ category.id }}" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="mdi mdi-plus mr-2"></i> Create Document
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Modal -->
|
||||||
|
<div id="category-modal" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center hidden">
|
||||||
|
<div class="bg-gray-800 rounded-lg shadow-lg w-full max-w-md mx-4">
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
||||||
|
<h3 id="modal-title" class="text-lg font-medium text-white">Add Subcategory</h3>
|
||||||
|
<button class="close-modal text-gray-400 hover:text-white">
|
||||||
|
<i class="mdi mdi-close text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<form id="category-form" class="space-y-4">
|
||||||
|
<input type="hidden" id="category-id" value="">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="category-name" class="block text-sm font-medium text-gray-400 mb-1">Name</label>
|
||||||
|
<input type="text" id="category-name" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="category-icon" class="block text-sm font-medium text-gray-400 mb-1">Icon</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="text" id="category-icon" value="mdi-folder-outline" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<div class="ml-3 w-10 h-10 flex items-center justify-center text-primary bg-primary/10 rounded-md">
|
||||||
|
<i id="icon-preview" class="mdi mdi-folder-outline text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button type="button" data-icon="mdi-folder-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-folder-outline"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-folder-text-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-folder-text-outline"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-code-braces" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-code-braces"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-database" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-database"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-web" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-web"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-book-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-book-outline"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="category-description" class="block text-sm font-medium text-gray-400 mb-1">Description (Optional)</label>
|
||||||
|
<input type="text" id="category-description" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="category-parent" class="block text-sm font-medium text-gray-400 mb-1">Parent Category</label>
|
||||||
|
<select id="category-parent" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="{{ category.id }}" selected>{{ category.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 flex justify-end space-x-3">
|
||||||
|
<button type="button" id="cancel-btn" class="px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const modal = document.getElementById('category-modal');
|
||||||
|
const addBtn = document.getElementById('add-subcategory-btn');
|
||||||
|
const addCard = document.getElementById('add-subcategory-card');
|
||||||
|
const editBtn = document.getElementById('edit-category-btn');
|
||||||
|
const closeBtn = document.querySelector('.close-modal');
|
||||||
|
const cancelBtn = document.getElementById('cancel-btn');
|
||||||
|
const form = document.getElementById('category-form');
|
||||||
|
const modalTitle = document.getElementById('modal-title');
|
||||||
|
const iconInput = document.getElementById('category-icon');
|
||||||
|
const iconPreview = document.getElementById('icon-preview');
|
||||||
|
const iconSelectBtns = document.querySelectorAll('.icon-select');
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
function openModal(isEdit = false) {
|
||||||
|
if (isEdit) {
|
||||||
|
// Edit the current category
|
||||||
|
modalTitle.textContent = 'Edit Category';
|
||||||
|
document.getElementById('category-id').value = '{{ category.id }}';
|
||||||
|
document.getElementById('category-name').value = '{{ category.name }}';
|
||||||
|
document.getElementById('category-icon').value = '{{ category.icon }}';
|
||||||
|
document.getElementById('category-parent').value = '{{ category.parent_id }}' || '';
|
||||||
|
iconPreview.className = 'mdi {{ category.icon }} text-xl';
|
||||||
|
} else {
|
||||||
|
// Add new subcategory
|
||||||
|
modalTitle.textContent = 'Add Subcategory';
|
||||||
|
document.getElementById('category-id').value = '';
|
||||||
|
document.getElementById('category-name').value = '';
|
||||||
|
document.getElementById('category-icon').value = 'mdi-folder-outline';
|
||||||
|
document.getElementById('category-parent').value = '{{ category.id }}';
|
||||||
|
iconPreview.className = 'mdi mdi-folder-outline text-xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
document.getElementById('category-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
function closeModal() {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update icon preview
|
||||||
|
if (iconInput) {
|
||||||
|
iconInput.addEventListener('input', function() {
|
||||||
|
iconPreview.className = 'mdi ' + this.value + ' text-xl';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon selection
|
||||||
|
iconSelectBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const icon = this.getAttribute('data-icon');
|
||||||
|
iconInput.value = icon;
|
||||||
|
iconPreview.className = 'mdi ' + icon + ' text-xl';
|
||||||
|
|
||||||
|
// Highlight selected icon
|
||||||
|
iconSelectBtns.forEach(b => b.classList.remove('ring-2', 'ring-primary'));
|
||||||
|
this.classList.add('ring-2', 'ring-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
if (addBtn) addBtn.addEventListener('click', () => openModal(false));
|
||||||
|
if (addCard) addCard.addEventListener('click', () => openModal(false));
|
||||||
|
if (editBtn) editBtn.addEventListener('click', () => openModal(true));
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', closeModal);
|
||||||
|
if (cancelBtn) cancelBtn.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
id: document.getElementById('category-id').value || null,
|
||||||
|
name: document.getElementById('category-name').value,
|
||||||
|
icon: document.getElementById('category-icon').value,
|
||||||
|
description: document.getElementById('category-description').value,
|
||||||
|
parent_id: document.getElementById('category-parent').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/category', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown functionality for document cards
|
||||||
|
document.querySelectorAll('.dropdown button').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = this.nextElementSibling;
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
|
||||||
|
// Close other open dropdowns
|
||||||
|
document.querySelectorAll('.dropdown-menu:not(.hidden)').forEach(m => {
|
||||||
|
if (m !== menu) m.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.dropdown-menu:not(.hidden)').forEach(menu => {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
491
app/templates/document_edit.html
Normal file
491
app/templates/document_edit.html
Normal file
|
@ -0,0 +1,491 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if document %}Edit: {{ document.title }}{% else %}New Document{% endif %} - Vim Docs{% endblock %}
|
||||||
|
|
||||||
|
{% block header_title %}{% if document %}Edit: {{ document.title }}{% else %}New Document{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block header_actions %}
|
||||||
|
<button id="save-document" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="mdi mdi-content-save mr-2"></i> Save
|
||||||
|
</button>
|
||||||
|
{% if document and document.id %}
|
||||||
|
<a href="{{ url_for('main.export_document', doc_id=document.id) }}" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors ml-2">
|
||||||
|
<i class="mdi mdi-download mr-2"></i> Export
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button id="toggle-preview" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors ml-2">
|
||||||
|
<i class="mdi mdi-eye mr-2"></i> Toggle Preview
|
||||||
|
</button>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<!-- CodeMirror CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/dialog/dialog.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/hint/show-hint.min.css">
|
||||||
|
|
||||||
|
<!-- GitHub Markdown CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown-dark.min.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Editor-specific styles */
|
||||||
|
.editor-container {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane, .preview-pane {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
position: relative;
|
||||||
|
font-family: 'CascadyaCove NF', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
padding: 0;
|
||||||
|
border-left: 1px solid #374151;
|
||||||
|
background-color: #0d1117; /* GitHub dark theme background */
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeMirror {
|
||||||
|
font-family: 'CascadyaCove NF', monospace !important;
|
||||||
|
height: 100% !important;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-horizontal .editor-container {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-vertical .editor-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
color: #c9d1d9; /* GitHub dark theme text color */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GitHub-style admonitions/alerts */
|
||||||
|
.markdown-body .admonition {
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 4px solid;
|
||||||
|
margin: 1em 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgba(175, 184, 193, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Regular blockquotes */
|
||||||
|
.markdown-body blockquote {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: #8b949e;
|
||||||
|
border-left: 0.25em solid #30363d;
|
||||||
|
margin: 1em 0;
|
||||||
|
background-color: rgba(55, 65, 81, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-note {
|
||||||
|
border-color: #2b6eff;
|
||||||
|
background-color: rgba(43, 110, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-note .admonition-title {
|
||||||
|
color: #2b6eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-tip {
|
||||||
|
border-color: #3fb950;
|
||||||
|
background-color: rgba(63, 185, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-tip .admonition-title {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-important {
|
||||||
|
border-color: #a371f7;
|
||||||
|
background-color: rgba(163, 113, 247, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-important .admonition-title {
|
||||||
|
color: #a371f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-warning {
|
||||||
|
border-color: #d29922;
|
||||||
|
background-color: rgba(210, 153, 34, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-warning .admonition-title {
|
||||||
|
color: #d29922;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-caution {
|
||||||
|
border-color: #f85149;
|
||||||
|
background-color: rgba(248, 81, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-caution .admonition-title {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-danger {
|
||||||
|
border-color: #cf222e;
|
||||||
|
background-color: rgba(207, 34, 46, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-danger .admonition-title {
|
||||||
|
color: #cf222e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Document info bar -->
|
||||||
|
<div class="bg-gray-800 rounded-lg mb-4 shadow-lg overflow-hidden">
|
||||||
|
<div class="p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="doc-title" class="block text-sm font-medium text-gray-400 mb-1">Document Title</label>
|
||||||
|
<input type="text" id="doc-title"
|
||||||
|
value="{% if document %}{{ document.title }}{% else %}Untitled Document{% endif %}"
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="doc-category" class="block text-sm font-medium text-gray-400 mb-1">Category</label>
|
||||||
|
<select id="doc-category" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
{% for category in categories %}
|
||||||
|
{% if category.is_root %}
|
||||||
|
<option value="{{ category.id }}" {% if (document and document.category_id == category.id) or (not document and (not preselected_category_id or preselected_category_id|int == category.id|int)) %}selected{% endif %}>
|
||||||
|
{{ category.name }} (Home)
|
||||||
|
</option>
|
||||||
|
{% else %}
|
||||||
|
<option value="{{ category.id }}" {% if (document and document.category_id == category.id) or (not document and preselected_category_id and preselected_category_id|int == category.id|int) %}selected{% endif %}>
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="doc-tags" class="block text-sm font-medium text-gray-400 mb-1">Tags</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="doc-tags" placeholder="Add tags..."
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
|
||||||
|
<div class="tag-list flex flex-wrap gap-1 mt-2">
|
||||||
|
{% if document and document.tags %}
|
||||||
|
{% for tag in document.tags %}
|
||||||
|
<span class="tag px-2 py-1 bg-primary/20 text-primary rounded-full text-xs flex items-center">
|
||||||
|
{{ tag.name }}
|
||||||
|
<span class="remove-tag ml-1 cursor-pointer">×</span>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor container -->
|
||||||
|
<div class="editor-container bg-gray-800 rounded-lg overflow-hidden shadow-lg">
|
||||||
|
<div class="editor-pane">
|
||||||
|
<textarea id="editor">{% if document %}{{ document.content }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="preview-pane">
|
||||||
|
<div id="preview" class="markdown-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save notification -->
|
||||||
|
<div id="save-notification" class="fixed bottom-4 right-4 bg-green-500/90 text-white px-4 py-2 rounded-md shadow-lg transform translate-y-16 opacity-0 transition-all duration-300 flex items-center">
|
||||||
|
<i class="mdi mdi-check-circle mr-2"></i>
|
||||||
|
<span>Document saved successfully!</span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<!-- CodeMirror JS -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/markdown/markdown.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/continuelist.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/closebrackets.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/mode/overlay.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/selection/active-line.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/dialog/dialog.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/search/searchcursor.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/search/search.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/keymap/vim.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Marked.js for Markdown -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked@4.0.16/marked.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Initialize CodeMirror
|
||||||
|
const editor = CodeMirror.fromTextArea(document.getElementById('editor'), {
|
||||||
|
mode: 'markdown',
|
||||||
|
theme: 'dracula',
|
||||||
|
lineNumbers: true,
|
||||||
|
lineWrapping: true,
|
||||||
|
indentWithTabs: false,
|
||||||
|
tabSize: 2,
|
||||||
|
keyMap: 'vim',
|
||||||
|
styleActiveLine: true,
|
||||||
|
autoCloseBrackets: true,
|
||||||
|
extraKeys: {
|
||||||
|
"Enter": "newlineAndIndentContinueMarkdownList"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup marked.js for markdown rendering
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true, // GitHub flavored markdown
|
||||||
|
breaks: true, // Convert newlines to <br>
|
||||||
|
headerIds: true,
|
||||||
|
sanitize: false, // Allow raw HTML
|
||||||
|
smartLists: true,
|
||||||
|
smartypants: true, // Typographic fixes
|
||||||
|
highlight: function(code) {
|
||||||
|
return `<pre><code>${code}</code></pre>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom renderer for handling GitHub-style alert blocks
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
|
||||||
|
// Store the original blockquote renderer
|
||||||
|
const originalBlockquote = renderer.blockquote;
|
||||||
|
|
||||||
|
// Override blockquote to support admonitions
|
||||||
|
renderer.blockquote = function(quote) {
|
||||||
|
// Check for GitHub-style admonition format [!NOTE], [!TIP], etc.
|
||||||
|
const text = quote.replace(/<\/?p>/g, ''); // Extract text without p tags
|
||||||
|
const admonitionRegex = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER)\]\s*([\s\S]*)/i;
|
||||||
|
const match = text.match(admonitionRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const type = match[1].toLowerCase();
|
||||||
|
const title = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
|
||||||
|
const content = match[2] ? match[2].trim() : '';
|
||||||
|
|
||||||
|
return `<div class="admonition admonition-${type}">
|
||||||
|
<p class="admonition-title">${title}</p>
|
||||||
|
<p>${content}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the original blockquote renderer
|
||||||
|
return originalBlockquote.call(this, quote);
|
||||||
|
};
|
||||||
|
|
||||||
|
marked.use({ renderer });
|
||||||
|
|
||||||
|
// Update markdown preview
|
||||||
|
function updatePreview() {
|
||||||
|
const content = editor.getValue();
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
preview.innerHTML = marked.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initial preview
|
||||||
|
updatePreview();
|
||||||
|
|
||||||
|
// Add debounce function for efficiency
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function() {
|
||||||
|
const context = this;
|
||||||
|
const args = arguments;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced preview update
|
||||||
|
const debouncedUpdate = debounce(updatePreview, 300);
|
||||||
|
|
||||||
|
// Update preview when content changes
|
||||||
|
editor.on('change', debouncedUpdate);
|
||||||
|
|
||||||
|
// Sync scroll between editor and preview
|
||||||
|
const previewPane = document.querySelector('.preview-pane');
|
||||||
|
|
||||||
|
// Editor to Preview scroll sync
|
||||||
|
editor.on('scroll', function() {
|
||||||
|
const scrollInfo = editor.getScrollInfo();
|
||||||
|
const ratio = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
|
||||||
|
const previewHeight = previewPane.scrollHeight - previewPane.clientHeight;
|
||||||
|
|
||||||
|
// Add a flag to prevent infinite scroll loop
|
||||||
|
if (!previewPane.isScrolling) {
|
||||||
|
editor.isScrolling = true;
|
||||||
|
previewPane.scrollTop = ratio * previewHeight;
|
||||||
|
setTimeout(() => { editor.isScrolling = false; }, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preview to Editor scroll sync
|
||||||
|
previewPane.addEventListener('scroll', function() {
|
||||||
|
if (!editor.isScrolling) {
|
||||||
|
const ratio = previewPane.scrollTop / (previewPane.scrollHeight - previewPane.clientHeight);
|
||||||
|
const scrollInfo = editor.getScrollInfo();
|
||||||
|
const editorHeight = scrollInfo.height - scrollInfo.clientHeight;
|
||||||
|
|
||||||
|
previewPane.isScrolling = true;
|
||||||
|
editor.scrollTo(null, ratio * editorHeight);
|
||||||
|
setTimeout(() => { previewPane.isScrolling = false; }, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle preview pane
|
||||||
|
const toggleBtn = document.getElementById('toggle-preview');
|
||||||
|
const editorContainer = document.querySelector('.editor-container');
|
||||||
|
const previewElement = document.querySelector('.preview-pane');
|
||||||
|
|
||||||
|
toggleBtn.addEventListener('click', function() {
|
||||||
|
previewElement.classList.toggle('hidden');
|
||||||
|
|
||||||
|
if (previewElement.classList.contains('hidden')) {
|
||||||
|
toggleBtn.innerHTML = '<i class="mdi mdi-eye-outline mr-2"></i> Show Preview';
|
||||||
|
} else {
|
||||||
|
toggleBtn.innerHTML = '<i class="mdi mdi-eye-off-outline mr-2"></i> Hide Preview';
|
||||||
|
updatePreview(); // Refresh preview when showing
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save document
|
||||||
|
const saveButton = document.getElementById('save-document');
|
||||||
|
const titleInput = document.getElementById('doc-title');
|
||||||
|
const categorySelect = document.getElementById('doc-category');
|
||||||
|
const notification = document.getElementById('save-notification');
|
||||||
|
|
||||||
|
saveButton.addEventListener('click', function() {
|
||||||
|
console.log("Save button clicked");
|
||||||
|
|
||||||
|
const documentData = {
|
||||||
|
title: titleInput.value.trim() || 'Untitled Document',
|
||||||
|
content: editor.getValue(),
|
||||||
|
category_id: categorySelect.value,
|
||||||
|
tags: Array.from(document.querySelectorAll('.tag')).map(tag => tag.textContent.trim())
|
||||||
|
};
|
||||||
|
|
||||||
|
{% if document and document.id %}
|
||||||
|
documentData.id = {{ document.id }};
|
||||||
|
console.log("Updating existing document:", documentData.id);
|
||||||
|
{% else %}
|
||||||
|
console.log("Creating new document");
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
console.log("Sending document data:", documentData);
|
||||||
|
|
||||||
|
fetch('/api/document', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(documentData)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Server responded with an error: ' + response.status);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
console.log("Document saved successfully:", data);
|
||||||
|
|
||||||
|
// Show save notification
|
||||||
|
notification.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
|
||||||
|
// Hide notification after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('translate-y-16', 'opacity-0');
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// If this is a new document, redirect to edit page
|
||||||
|
{% if not document %}
|
||||||
|
if (data.id) {
|
||||||
|
console.log("Redirecting to edit page for new document:", data.id);
|
||||||
|
window.location.href = `/document/${data.id}/edit`;
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error saving document:', error);
|
||||||
|
alert('Error saving document: ' + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tags functionality
|
||||||
|
const tagInput = document.getElementById('doc-tags');
|
||||||
|
const tagList = document.querySelector('.tag-list');
|
||||||
|
|
||||||
|
function addTag(tagName) {
|
||||||
|
const tag = document.createElement('span');
|
||||||
|
tag.className = 'tag px-2 py-1 bg-primary/20 text-primary rounded-full text-xs flex items-center';
|
||||||
|
tag.innerHTML = `${tagName}<span class="remove-tag ml-1 cursor-pointer">×</span>`;
|
||||||
|
|
||||||
|
// Add remove functionality
|
||||||
|
tag.querySelector('.remove-tag').addEventListener('click', function() {
|
||||||
|
tag.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
tagList.appendChild(tag);
|
||||||
|
tagInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
tagInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const tagName = tagInput.value.trim();
|
||||||
|
if (tagName) {
|
||||||
|
// Check for duplicate
|
||||||
|
const existingTags = Array.from(document.querySelectorAll('.tag')).map(tag =>
|
||||||
|
tag.textContent.trim().toLowerCase());
|
||||||
|
|
||||||
|
if (!existingTags.includes(tagName.toLowerCase())) {
|
||||||
|
addTag(tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup existing tag removal
|
||||||
|
document.querySelectorAll('.remove-tag').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
this.parentElement.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
278
app/templates/document_view.html
Normal file
278
app/templates/document_view.html
Normal file
|
@ -0,0 +1,278 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ document.title }} - Vim Docs{% endblock %}
|
||||||
|
|
||||||
|
{% block header_title %}{{ document.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block header_actions %}
|
||||||
|
<a href="{{ url_for('main.edit_document', doc_id=document.id) }}" class="button primary">
|
||||||
|
<i class="mdi mdi-pencil"></i> Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.export_document', doc_id=document.id) }}" class="button">
|
||||||
|
<i class="mdi mdi-download"></i> Export
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<!-- GitHub Markdown CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown-dark.min.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.document-view {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #0d1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-metadata {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-item {
|
||||||
|
margin-right: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-item i {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-tag {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: var(--bg-color);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-tag:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GitHub-style admonitions/alerts */
|
||||||
|
.markdown-body .admonition {
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 4px solid;
|
||||||
|
margin: 1em 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgba(175, 184, 193, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-note {
|
||||||
|
border-color: #2b6eff;
|
||||||
|
background-color: rgba(43, 110, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-note .admonition-title {
|
||||||
|
color: #2b6eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-tip {
|
||||||
|
border-color: #3fb950;
|
||||||
|
background-color: rgba(63, 185, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-tip .admonition-title {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-important {
|
||||||
|
border-color: #a371f7;
|
||||||
|
background-color: rgba(163, 113, 247, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-important .admonition-title {
|
||||||
|
color: #a371f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-warning {
|
||||||
|
border-color: #d29922;
|
||||||
|
background-color: rgba(210, 153, 34, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-warning .admonition-title {
|
||||||
|
color: #d29922;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-caution {
|
||||||
|
border-color: #f85149;
|
||||||
|
background-color: rgba(248, 81, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .admonition-caution .admonition-title {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Regular blockquotes */
|
||||||
|
.markdown-body blockquote {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: #8b949e;
|
||||||
|
border-left: 0.25em solid #30363d;
|
||||||
|
margin: 1em 0;
|
||||||
|
background-color: rgba(55, 65, 81, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="document-view">
|
||||||
|
<div class="document-metadata">
|
||||||
|
<div class="metadata-item">
|
||||||
|
<i class="mdi mdi-calendar"></i>
|
||||||
|
Created: {{ document.created_date.strftime('%b %d, %Y') }}
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<i class="mdi mdi-update"></i>
|
||||||
|
Updated: {{ document.updated_date.strftime('%b %d, %Y') }}
|
||||||
|
</div>
|
||||||
|
{% if document.category %}
|
||||||
|
<div class="metadata-item">
|
||||||
|
<i class="mdi {{ document.category.icon }}"></i>
|
||||||
|
{{ document.category.name }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if document.tags %}
|
||||||
|
<div class="metadata-item document-tags">
|
||||||
|
<i class="mdi mdi-tag-multiple"></i>
|
||||||
|
{% for tag in document.tags %}
|
||||||
|
<span class="document-tag">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="markdown-body" id="document-content" data-content="{{ document.content|tojson|safe }}">
|
||||||
|
<!-- Content will be rendered here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<!-- Marked.js for Markdown parsing -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Markdown rendering configuration
|
||||||
|
marked.setOptions({
|
||||||
|
renderer: new marked.Renderer(),
|
||||||
|
gfm: true,
|
||||||
|
tables: true,
|
||||||
|
breaks: false,
|
||||||
|
pedantic: false,
|
||||||
|
sanitize: false,
|
||||||
|
smartLists: true,
|
||||||
|
smartypants: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom renderer for GitHub-style alert blocks
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
|
||||||
|
// Fix the link renderer
|
||||||
|
const originalLink = renderer.link;
|
||||||
|
renderer.link = function(href, title, text) {
|
||||||
|
const html = originalLink.call(this, href, title, text);
|
||||||
|
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fix the blockquote renderer for GitHub-style alert blocks
|
||||||
|
const originalBlockquote = renderer.blockquote;
|
||||||
|
renderer.blockquote = function(quote) {
|
||||||
|
// Extract text without p tags
|
||||||
|
const text = quote.replace(/<\/?p>/g, '');
|
||||||
|
// Pattern for GitHub-style alerts: > [!NOTE], > [!WARNING], etc.
|
||||||
|
const admonitionRegex = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER)\]\s*([\s\S]*)/i;
|
||||||
|
const match = text.match(admonitionRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const type = match[1].toLowerCase();
|
||||||
|
const title = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
|
||||||
|
const content = match[2] ? match[2].trim() : '';
|
||||||
|
|
||||||
|
return `<div class="admonition admonition-${type}">
|
||||||
|
<p class="admonition-title">${title}</p>
|
||||||
|
<p>${content}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the original blockquote renderer
|
||||||
|
return originalBlockquote.call(this, quote);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply custom renderer
|
||||||
|
marked.use({ renderer: renderer });
|
||||||
|
|
||||||
|
// Render markdown content
|
||||||
|
const documentContent = document.getElementById('document-content');
|
||||||
|
const markdownContent = documentContent.getAttribute('data-content');
|
||||||
|
|
||||||
|
console.log("Raw content attribute:", markdownContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse the JSON-encoded content (Flask's tojson filter wraps content in quotes)
|
||||||
|
const decodedContent = JSON.parse(markdownContent);
|
||||||
|
console.log("Content length:", decodedContent.length);
|
||||||
|
|
||||||
|
// Render the markdown directly without additional checks
|
||||||
|
documentContent.innerHTML = marked.parse(decodedContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error rendering document:", e);
|
||||||
|
documentContent.innerHTML = '<div class="text-center p-8 text-red-500">Error displaying document content.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add linkification for dynamic links
|
||||||
|
document.querySelectorAll('#document-content a[href^="#doc:"]').forEach(link => {
|
||||||
|
const docId = link.getAttribute('href').replace('#doc:', '');
|
||||||
|
link.setAttribute('href', `/document/${docId}`);
|
||||||
|
// Don't open internal doc links in new tab
|
||||||
|
link.removeAttribute('target');
|
||||||
|
link.removeAttribute('rel');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
// 'e' to edit the current document
|
||||||
|
if (e.key === 'e' && !e.ctrlKey && !e.metaKey && !e.altKey &&
|
||||||
|
!(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = "{{ url_for('main.edit_document', doc_id=document.id) }}";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
400
app/templates/index.html
Normal file
400
app/templates/index.html
Normal file
|
@ -0,0 +1,400 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Vim Docs{% endblock %}
|
||||||
|
|
||||||
|
{% block header_title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Welcome banner -->
|
||||||
|
<div class="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Welcome back, {{ current_user.username }}!</h1>
|
||||||
|
<p class="text-gray-400 mb-4">Manage your Vim and coding documentation with ease.</p>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<a href="{{ url_for('main.new_document') }}" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="mdi mdi-file-plus-outline mr-2"></i> New Document
|
||||||
|
</a>
|
||||||
|
<button id="category-btn" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors">
|
||||||
|
<i class="mdi mdi-folder-plus-outline mr-2"></i> New Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<i class="mdi mdi-vim text-primary text-9xl opacity-20"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent documents section -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-white">Recent Documents</h2>
|
||||||
|
<a href="#" class="text-primary hover:text-primary-light flex items-center text-sm">
|
||||||
|
View All <i class="mdi mdi-chevron-right ml-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% if recent_docs %}
|
||||||
|
{% for doc in recent_docs %}
|
||||||
|
<div class="bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-shadow">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<h3 class="text-white font-medium mb-2 truncate">
|
||||||
|
<a href="{{ url_for('main.view_document', doc_id=doc.id) }}" class="hover:text-primary transition-colors">
|
||||||
|
{{ doc.title }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<div class="dropdown relative ml-2">
|
||||||
|
<button class="icon-button p-1">
|
||||||
|
<i class="mdi mdi-dots-vertical"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu hidden absolute right-0 mt-2 w-40 bg-gray-700 rounded-md shadow-lg z-10">
|
||||||
|
<a href="{{ url_for('main.edit_document', doc_id=doc.id) }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-600 hover:text-white">
|
||||||
|
<i class="mdi mdi-pencil mr-2"></i> Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.export_document', doc_id=doc.id) }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-600 hover:text-white">
|
||||||
|
<i class="mdi mdi-download mr-2"></i> Export
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-gray-400 text-sm mb-3 truncate">
|
||||||
|
{{ (doc.content[:100] + '...') if doc.content|length > 100 else doc.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<i class="mdi mdi-calendar-outline mr-1"></i> {{ doc.updated_date.strftime('%b %d, %Y') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if doc.category %}
|
||||||
|
<div class="flex items-center text-xs text-gray-500">
|
||||||
|
<i class="mdi {{ doc.category.icon }} mr-1"></i> {{ doc.category.name }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if doc.tags %}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-3">
|
||||||
|
{% for tag in doc.tags %}
|
||||||
|
<span class="text-xs px-2 py-1 bg-primary/20 text-primary rounded-full">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="col-span-full bg-gray-800/50 rounded-lg p-8 text-center">
|
||||||
|
<i class="mdi mdi-file-document-outline text-6xl text-gray-700 mb-3"></i>
|
||||||
|
<h3 class="text-lg text-gray-400 mb-3">No documents yet</h3>
|
||||||
|
<p class="text-gray-500 mb-4">Create your first document to get started</p>
|
||||||
|
<a href="{{ url_for('main.new_document') }}" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="mdi mdi-plus mr-2"></i> Create Document
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories section -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-white">My Categories</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{% if categories %}
|
||||||
|
{% for category in categories %}
|
||||||
|
<div class="bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-all hover:-translate-y-1 duration-200">
|
||||||
|
<a href="{{ url_for('main.view_category', category_id=category.id) }}" class="block p-5">
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<div class="w-10 h-10 rounded-md bg-primary/20 flex items-center justify-center text-primary mr-3">
|
||||||
|
<i class="mdi {{ category.icon }} text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-white font-medium truncate">{{ category.name }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if category.description %}
|
||||||
|
<p class="text-gray-400 text-sm mb-4 line-clamp-2">{{ category.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>{{ category.documents.count() }} document{{ '' if category.documents.count() == 1 else 's' }}</span>
|
||||||
|
<span>{{ category.children.count() }} subcategor{{ 'y' if category.children.count() == 1 else 'ies' }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Add category card -->
|
||||||
|
<div id="add-category-card" class="bg-gray-800/50 border-2 border-dashed border-gray-700 rounded-lg overflow-hidden hover:border-primary/50 hover:bg-gray-800/80 transition-all cursor-pointer">
|
||||||
|
<div class="p-5 h-full flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-gray-700/50 flex items-center justify-center mb-3">
|
||||||
|
<i class="mdi mdi-folder-plus-outline text-2xl text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-gray-400 font-medium mb-1">New Category</h3>
|
||||||
|
<p class="text-gray-500 text-sm">Create a new category to organize your docs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-span-full bg-gray-800/50 rounded-lg p-8 text-center">
|
||||||
|
<i class="mdi mdi-folder-outline text-6xl text-gray-700 mb-3"></i>
|
||||||
|
<h3 class="text-lg text-gray-400 mb-3">No categories yet</h3>
|
||||||
|
<p class="text-gray-500 mb-4">Organize your documents by creating categories</p>
|
||||||
|
<button id="empty-add-category-btn" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="mdi mdi-plus mr-2"></i> Create Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Modal -->
|
||||||
|
<div id="category-modal" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center hidden">
|
||||||
|
<div class="bg-gray-800 rounded-lg shadow-lg w-full max-w-md mx-4">
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
||||||
|
<h3 id="modal-title" class="text-lg font-medium text-white">Add Category</h3>
|
||||||
|
<button id="close-modal" class="text-gray-400 hover:text-white">
|
||||||
|
<i class="mdi mdi-close text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<form id="category-form" class="space-y-4">
|
||||||
|
<input type="hidden" id="category-id" value="">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="category-name" class="block text-sm font-medium text-gray-400 mb-1">Name</label>
|
||||||
|
<input type="text" id="category-name" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="category-icon" class="block text-sm font-medium text-gray-400 mb-1">Icon</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="text" id="category-icon" value="mdi-folder-outline" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<div class="ml-3 w-10 h-10 flex items-center justify-center text-primary bg-primary/10 rounded-md">
|
||||||
|
<i id="icon-preview" class="mdi mdi-folder-outline text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button type="button" data-icon="mdi-folder-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-folder-outline"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-folder-text-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-folder-text-outline"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-code-braces" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-code-braces"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-database" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-database"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-web" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-web"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-icon="mdi-book-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
|
||||||
|
<i class="mdi mdi-book-outline"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="category-description" class="block text-sm font-medium text-gray-400 mb-1">Description (Optional)</label>
|
||||||
|
<input type="text" id="category-description" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="category-parent" class="block text-sm font-medium text-gray-400 mb-1">Parent Category</label>
|
||||||
|
<select id="category-parent" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="">None</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}">{{ category.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 flex justify-end space-x-3">
|
||||||
|
<button type="button" id="cancel-btn" class="px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Modal JavaScript -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log("DOM content loaded, initializing category modal");
|
||||||
|
|
||||||
|
// Category modal functionality
|
||||||
|
const modal = document.getElementById('category-modal');
|
||||||
|
const addCategoryBtn = document.getElementById('category-btn');
|
||||||
|
const addCategoryCard = document.getElementById('add-category-card');
|
||||||
|
const emptyAddBtn = document.getElementById('empty-add-category-btn');
|
||||||
|
const closeModalBtn = document.getElementById('close-modal');
|
||||||
|
const cancelBtn = document.getElementById('cancel-btn');
|
||||||
|
const form = document.getElementById('category-form');
|
||||||
|
const iconInput = document.getElementById('category-icon');
|
||||||
|
const iconPreview = document.getElementById('icon-preview');
|
||||||
|
const iconSelectBtns = document.querySelectorAll('.icon-select');
|
||||||
|
|
||||||
|
// Open modal function
|
||||||
|
function openModal() {
|
||||||
|
console.log("Opening category modal");
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
document.getElementById('category-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal function
|
||||||
|
function closeModal() {
|
||||||
|
console.log("Closing category modal");
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('category-id').value = '';
|
||||||
|
iconPreview.className = 'mdi mdi-folder-outline text-xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update icon preview
|
||||||
|
if (iconInput) {
|
||||||
|
iconInput.addEventListener('input', function() {
|
||||||
|
iconPreview.className = 'mdi ' + this.value + ' text-xl';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon selection
|
||||||
|
iconSelectBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const icon = this.getAttribute('data-icon');
|
||||||
|
iconInput.value = icon;
|
||||||
|
iconPreview.className = 'mdi ' + icon + ' text-xl';
|
||||||
|
|
||||||
|
// Highlight selected icon
|
||||||
|
iconSelectBtns.forEach(b => b.classList.remove('ring-2', 'ring-primary'));
|
||||||
|
this.classList.add('ring-2', 'ring-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event listeners for opening the modal
|
||||||
|
console.log("Setting up event listeners");
|
||||||
|
if (addCategoryBtn) {
|
||||||
|
console.log("Found category-btn, adding click handler");
|
||||||
|
addCategoryBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
openModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addCategoryCard) {
|
||||||
|
console.log("Found add-category-card, adding click handler");
|
||||||
|
addCategoryCard.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
openModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyAddBtn) {
|
||||||
|
console.log("Found empty-add-category-btn, adding click handler");
|
||||||
|
emptyAddBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
openModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners for closing the modal
|
||||||
|
if (closeModalBtn) {
|
||||||
|
closeModalBtn.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click outside to close
|
||||||
|
window.addEventListener('click', function(e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log("Category form submitted");
|
||||||
|
|
||||||
|
const categoryData = {
|
||||||
|
name: document.getElementById('category-name').value,
|
||||||
|
icon: document.getElementById('category-icon').value,
|
||||||
|
description: document.getElementById('category-description').value,
|
||||||
|
parent_id: document.getElementById('category-parent').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryId = document.getElementById('category-id').value;
|
||||||
|
if (categoryId) {
|
||||||
|
categoryData.id = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Sending category data:", categoryData);
|
||||||
|
|
||||||
|
fetch('/api/category', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(categoryData)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Server responded with an error: ' + response.status);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
console.log("Category saved successfully:", data);
|
||||||
|
// Reload the page to show the new category
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error saving category:', error);
|
||||||
|
alert('Error saving category: ' + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown functionality for document cards
|
||||||
|
document.querySelectorAll('.dropdown button').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = this.nextElementSibling;
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
|
||||||
|
// Close other open dropdowns
|
||||||
|
document.querySelectorAll('.dropdown-menu:not(.hidden)').forEach(m => {
|
||||||
|
if (m !== menu) m.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.dropdown-menu:not(.hidden)').forEach(menu => {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
117
create_db.py
Normal file
117
create_db.py
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
from app import app, db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.document import Document, Category, Tag
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
import os
|
||||||
|
|
||||||
|
def setup_database():
|
||||||
|
"""Set up the database tables and create a demo user if none exists."""
|
||||||
|
with app.app_context():
|
||||||
|
# Create all tables
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# Check if any users exist
|
||||||
|
if User.query.count() == 0:
|
||||||
|
print('Creating demo user...')
|
||||||
|
|
||||||
|
# Create demo user
|
||||||
|
demo_user = User(username='demo')
|
||||||
|
demo_user.set_password('password')
|
||||||
|
db.session.add(demo_user)
|
||||||
|
db.session.flush() # To get the user ID
|
||||||
|
|
||||||
|
# Create a root category for the demo user
|
||||||
|
root_category = Category(
|
||||||
|
name='My Documents',
|
||||||
|
icon='mdi-folder',
|
||||||
|
description='Default document category',
|
||||||
|
user_id=demo_user.id,
|
||||||
|
is_root=True
|
||||||
|
)
|
||||||
|
db.session.add(root_category)
|
||||||
|
|
||||||
|
# Create some sample categories
|
||||||
|
categories = [
|
||||||
|
Category(
|
||||||
|
name='Vim Commands',
|
||||||
|
icon='mdi-vim',
|
||||||
|
user_id=demo_user.id,
|
||||||
|
description='Essential Vim commands and shortcuts'
|
||||||
|
),
|
||||||
|
Category(
|
||||||
|
name='Flask Development',
|
||||||
|
icon='mdi-flask',
|
||||||
|
user_id=demo_user.id,
|
||||||
|
description='Flask web development notes'
|
||||||
|
),
|
||||||
|
Category(
|
||||||
|
name='Python Snippets',
|
||||||
|
icon='mdi-language-python',
|
||||||
|
user_id=demo_user.id,
|
||||||
|
description='Useful Python code snippets'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
db.session.add(category)
|
||||||
|
|
||||||
|
# Create a sample document
|
||||||
|
sample_doc = Document(
|
||||||
|
title='Getting Started with Vim',
|
||||||
|
content="""# Getting Started with Vim
|
||||||
|
|
||||||
|
## Basic Commands
|
||||||
|
|
||||||
|
### Movement
|
||||||
|
- `h` - move left
|
||||||
|
- `j` - move down
|
||||||
|
- `k` - move up
|
||||||
|
- `l` - move right
|
||||||
|
|
||||||
|
### Modes
|
||||||
|
- `i` - enter insert mode
|
||||||
|
- `Esc` - return to normal mode
|
||||||
|
- `v` - enter visual mode
|
||||||
|
- `:` - enter command mode
|
||||||
|
|
||||||
|
> Vim has a steep learning curve, but it's worth it!
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Use `vimtutor` to learn Vim basics interactively.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Vim is available on almost all Unix-like systems.
|
||||||
|
""",
|
||||||
|
user_id=demo_user.id,
|
||||||
|
category_id=categories[0].id
|
||||||
|
)
|
||||||
|
db.session.add(sample_doc)
|
||||||
|
|
||||||
|
# Create some tags
|
||||||
|
tags = [
|
||||||
|
Tag(name='vim', user_id=demo_user.id, color='#50fa7b'),
|
||||||
|
Tag(name='editor', user_id=demo_user.id, color='#bd93f9'),
|
||||||
|
Tag(name='tutorial', user_id=demo_user.id, color='#ff79c6')
|
||||||
|
]
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
db.session.add(tag)
|
||||||
|
|
||||||
|
# Associate tags with the document
|
||||||
|
sample_doc.tags = tags
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print('Demo user and sample data created successfully!')
|
||||||
|
else:
|
||||||
|
print('Database already contains users, skipping demo data creation.')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Create database file if it doesn't exist
|
||||||
|
db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app', 'docs.db')
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f'Creating database file at {db_path}')
|
||||||
|
|
||||||
|
setup_database()
|
||||||
|
print('Database setup complete!')
|
BIN
instance/docs.db
Normal file
BIN
instance/docs.db
Normal file
Binary file not shown.
13
requirements.txt
Normal file
13
requirements.txt
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
flask==3.0.0
|
||||||
|
flask-sqlalchemy==3.1.1
|
||||||
|
flask-login==0.6.3
|
||||||
|
flask-wtf==1.2.1
|
||||||
|
flask-migrate==4.0.5
|
||||||
|
Werkzeug==3.0.1
|
||||||
|
Jinja2==3.1.2
|
||||||
|
MarkupSafe==2.1.3
|
||||||
|
itsdangerous==2.1.2
|
||||||
|
blinker==1.6.2
|
||||||
|
click==8.1.7
|
||||||
|
email-validator==2.1.0
|
||||||
|
python-dotenv==1.0.0
|
6
run.py
Normal file
6
run.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from app import app, db
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
app.run(debug=True)
|
Loading…
Add table
Add a link
Reference in a new issue