commit 345a801c40c1c5acb40cd14dfb68a5377805b524 Author: pika Date: Mon Apr 14 22:25:26 2025 +0200 batman diff --git a/README.md b/README.md new file mode 100644 index 0000000..0618107 --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..eed53d0 --- /dev/null +++ b/app.py @@ -0,0 +1,4 @@ +from app import app + +if __name__ == '__main__': + app.run(debug=True) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..0e99557 --- /dev/null +++ b/app/__init__.py @@ -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 \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..039e464 Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/routes.cpython-313.pyc b/app/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..e35c0ac Binary files /dev/null and b/app/__pycache__/routes.cpython-313.pyc differ diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..35549e9 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from app.auth import routes \ No newline at end of file diff --git a/app/auth/__pycache__/__init__.cpython-313.pyc b/app/auth/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..64ff801 Binary files /dev/null and b/app/auth/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/auth/__pycache__/forms.cpython-313.pyc b/app/auth/__pycache__/forms.cpython-313.pyc new file mode 100644 index 0000000..fe292b5 Binary files /dev/null and b/app/auth/__pycache__/forms.cpython-313.pyc differ diff --git a/app/auth/__pycache__/routes.cpython-313.pyc b/app/auth/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..e33ac2d Binary files /dev/null and b/app/auth/__pycache__/routes.cpython-313.pyc differ diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000..88c7b4b --- /dev/null +++ b/app/auth/forms.py @@ -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.') \ No newline at end of file diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..d4693da --- /dev/null +++ b/app/auth/routes.py @@ -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') \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..70825c7 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +# Models package initialization \ No newline at end of file diff --git a/app/models/__pycache__/__init__.cpython-313.pyc b/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..674d89a Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/models/__pycache__/document.cpython-313.pyc b/app/models/__pycache__/document.cpython-313.pyc new file mode 100644 index 0000000..58f71a2 Binary files /dev/null and b/app/models/__pycache__/document.cpython-313.pyc differ diff --git a/app/models/__pycache__/user.cpython-313.pyc b/app/models/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000..a2bd566 Binary files /dev/null and b/app/models/__pycache__/user.cpython-313.pyc differ diff --git a/app/models/document.py b/app/models/document.py new file mode 100644 index 0000000..7958751 --- /dev/null +++ b/app/models/document.py @@ -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'' + + 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'' + + 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'' + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'user_id': self.user_id, + 'color': self.color, + 'document_count': self.documents.count() + } \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..0a5e233 --- /dev/null +++ b/app/models/user.py @@ -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'' + + 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)) \ No newline at end of file diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..bbf63b6 --- /dev/null +++ b/app/routes.py @@ -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/') +@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/', 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//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/', 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/', 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//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]) \ No newline at end of file diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..f0613ba --- /dev/null +++ b/app/static/css/style.css @@ -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)); + } +} \ No newline at end of file diff --git a/app/static/fonts/CascadiaCove.ttf b/app/static/fonts/CascadiaCove.ttf new file mode 100644 index 0000000..09e45e8 --- /dev/null +++ b/app/static/fonts/CascadiaCove.ttf @@ -0,0 +1,1817 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Skip to content + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..da526bb --- /dev/null +++ b/app/static/js/app.js @@ -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 = ` + + ${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 []; + }); +} \ No newline at end of file diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..ac903dd --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} + +{% block title %}Login - Vim Docs{% endblock %} + +{% block auth_content %} +
+
+
+

+ Vim Docs +

+

Sign in to your account

+
+ +
+ {{ form.hidden_tag() }} + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ +
+ {{ 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") }} +
+ {% for error in form.username.errors %} +

{{ error }}

+ {% endfor %} +
+ +
+ +
+ {{ 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") }} +
+ {% for error in form.password.errors %} +

{{ error }}

+ {% endfor %} +
+ +
+
+ {{ form.remember_me(class="h-4 w-4 text-primary focus:ring-primary border-gray-600 rounded bg-gray-700") }} + +
+
+ +
+ {{ 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") }} +
+ +
+

+ Don't have an account? + + Sign up + +

+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/settings.html b/app/templates/auth/settings.html new file mode 100644 index 0000000..576e341 --- /dev/null +++ b/app/templates/auth/settings.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} + +{% block title %}Settings - Vim Docs{% endblock %} + +{% block header_title %}Settings{% endblock %} + +{% block content %} +
+
+
+

User Settings

+

Customize your Vim Docs experience

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+

Theme Settings

+ +
+
+ +
+ + {{ current_user.theme_color }} +
+
+ +
+

Color Presets

+
+ + + + + + +
+
+
+
+ +
+ +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/signup.html b/app/templates/auth/signup.html new file mode 100644 index 0000000..049716c --- /dev/null +++ b/app/templates/auth/signup.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}Sign Up - Vim Docs{% endblock %} + +{% block auth_content %} +
+
+
+

+ Vim Docs +

+

Create your account

+
+ +
+ {{ form.hidden_tag() }} + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ +
+ {{ 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") }} +
+ {% for error in form.username.errors %} +

{{ error }}

+ {% endfor %} +
+ +
+ +
+ {{ 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") }} +
+ {% for error in form.password.errors %} +

{{ error }}

+ {% endfor %} +
+ +
+ +
+ {{ 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") }} +
+ {% for error in form.password2.errors %} +

{{ error }}

+ {% endfor %} +
+ +
+ {{ 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") }} +
+ +
+

+ Already have an account? + + Sign in + +

+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..4ffd79f --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,332 @@ + + + + + + {% block title %}Vim Docs{% endblock %} + + + + + + + + + + + + {% block extra_css %}{% endblock %} + + + {% if current_user.is_authenticated %} +
+ + + + +
+ +
+
+ +

{% block header_title %}Dashboard{% endblock %}

+ +
+
+ + +
+ +
+
+ +
+ {% block header_actions %}{% endblock %} + +
+ + + +
+
+
+ + +
+ {% block content %}{% endblock %} +
+
+
+ + + + {% else %} +
+ {% block auth_content %}{% endblock %} +
+ {% endif %} + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/app/templates/category.html b/app/templates/category.html new file mode 100644 index 0000000..00586bd --- /dev/null +++ b/app/templates/category.html @@ -0,0 +1,364 @@ +{% extends "base.html" %} + +{% block title %}{{ category.name }} - Vim Docs{% endblock %} + +{% block header_title %} + {{ category.name }} +{% endblock %} + +{% block header_actions %} + + New Document + + +{% endblock %} + +{% block content %} +
+ +
+
+
+ +
+

{{ category.name }}

+
+ +
+ {% if category.description %} + {{ category.description }} + {% else %} + A category for organizing your documents + {% endif %} +
+ +
+
+ + Home + {% if category.parent %} + / + {{ category.parent.name }} + {% endif %} + / + {{ category.name }} +
+
+
+ + + {% if category.children.count() > 0 %} + + {% endif %} + + +
+
+

Documents

+ + + +
+ +
+ {% if category.documents.count() > 0 %} + {% for doc in category.documents %} +
+
+ + +
+ {{ (doc.content[:100] + '...') if doc.content|length > 100 else doc.content }} +
+ +
+
+ {{ doc.updated_date.strftime('%b %d, %Y') }} +
+
+ + {% if doc.tags %} +
+ {% for tag in doc.tags %} + {{ tag.name }} + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} + {% else %} +
+ +

No documents in this category

+

Create your first document in this category

+ + Create Document + +
+ {% endif %} +
+
+
+ + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/document_edit.html b/app/templates/document_edit.html new file mode 100644 index 0000000..dac0651 --- /dev/null +++ b/app/templates/document_edit.html @@ -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 %} + +{% if document and document.id %} + + Export + +{% endif %} + +{% endblock %} + +{% block extra_css %} + + + + + + + + + + +{% endblock %} + +{% block content %} + +
+
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ {% if document and document.tags %} + {% for tag in document.tags %} + + {{ tag.name }} + × + + {% endfor %} + {% endif %} +
+
+
+
+
+ + +
+
+ +
+
+
+
+
+ + +
+ + Document saved successfully! +
+{% endblock %} + +{% block extra_js %} + + + + + + + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/document_view.html b/app/templates/document_view.html new file mode 100644 index 0000000..1dbcd7b --- /dev/null +++ b/app/templates/document_view.html @@ -0,0 +1,278 @@ +{% extends "base.html" %} + +{% block title %}{{ document.title }} - Vim Docs{% endblock %} + +{% block header_title %}{{ document.title }}{% endblock %} + +{% block header_actions %} + + Edit + + + Export + +{% endblock %} + +{% block extra_css %} + + + + +{% endblock %} + +{% block content %} +
+ + +
+ +
+
+{% endblock %} + +{% block extra_js %} + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..f63fc17 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,400 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Vim Docs{% endblock %} + +{% block header_title %}Dashboard{% endblock %} + +{% block content %} +
+ +
+
+
+
+

Welcome back, {{ current_user.username }}!

+

Manage your Vim and coding documentation with ease.

+
+ + New Document + + +
+
+ +
+
+
+ + +
+
+

Recent Documents

+ + View All + +
+ +
+ {% if recent_docs %} + {% for doc in recent_docs %} +
+
+ + +
+ {{ (doc.content[:100] + '...') if doc.content|length > 100 else doc.content }} +
+ +
+
+ {{ doc.updated_date.strftime('%b %d, %Y') }} +
+ + {% if doc.category %} +
+ {{ doc.category.name }} +
+ {% endif %} +
+ + {% if doc.tags %} +
+ {% for tag in doc.tags %} + {{ tag.name }} + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} + {% else %} +
+ +

No documents yet

+

Create your first document to get started

+ + Create Document + +
+ {% endif %} +
+
+ + +
+
+

My Categories

+
+ +
+ {% if categories %} + {% for category in categories %} + + {% endfor %} + + +
+
+
+ +
+

New Category

+

Create a new category to organize your docs

+
+
+ {% else %} +
+ +

No categories yet

+

Organize your documents by creating categories

+ +
+ {% endif %} +
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/create_db.py b/create_db.py new file mode 100644 index 0000000..3ea99db --- /dev/null +++ b/create_db.py @@ -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!') \ No newline at end of file diff --git a/instance/docs.db b/instance/docs.db new file mode 100644 index 0000000..a77b8df Binary files /dev/null and b/instance/docs.db differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98913a0 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..e8049d7 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import app, db + +if __name__ == '__main__': + with app.app_context(): + db.create_all() + app.run(debug=True)