commit 6ec01760eb2d3566d9b8b6cbbd125b7c63bc67a2 Author: Ben Miller Date: Tue May 12 18:58:18 2026 -0500 intial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..559f344 --- /dev/null +++ b/.gitignore @@ -0,0 +1,213 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Database Files +*.sqlite3 +*.sqlitedb +*.db +*.db-journal + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..f827b5f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,40 @@ +# SpotNotes Future Implementation & Refactoring Plan + +## 1. Refactoring `index.js` (Reducing Complexity) +- **Status:** **[To Do]** +- **Action Plan:** + - `index.js` is currently a monolithic file (~1000 lines) handling global state, Leaflet map initialization, sidebar DOM manipulation, and raw API calls simultaneously. + - **Extract Map Logic:** Create `static/assets/js/map.js` to encapsulate `initMap`, `renderMapMarkers`, geolocation tracking, and all Leaflet-specific code. + - **Extract UI/Sidebar Logic:** Create `static/assets/js/sidebar.js` to handle `initSidebar`, `renderNotepadList`, HTML generation for note list items, and DOM toggling. + - **Extract API & State Logic:** Keep the core data fetching (`loadNotepads`, `loadNotesForPad`, `loadAllMyNotes`) in `index.js` (or a dedicated `data.js`) to act as the central controller. + - **Namespace Globals:** Group global variables (`activeNotes`, `activePadId`, `currentUser`, `map`, etc.) into a single `window.SpotNotes` state object to prevent scope pollution and make dependencies explicitly clear across the new script files. + +## 2. Feature: "Get Directions" Button +- **Status:** **[To Do]** +- **Action Plan:** + - **Low-Hanging Fruit:** Add a quick "Directions" or "Navigate" link/button to the map marker popups and sidebar note items. + - Clicking the button will easily open a new tab mapping directly to the note's coordinates: `https://www.google.com/maps/dir/?api=1&destination={lat},{lng}`. + - Extremely useful UX for a location-based app, requiring absolutely zero backend changes. + +## 3. Feature: Map Marker Clustering +- **Status:** **[To Do]** +- **Action Plan:** + - **Low-Hanging Fruit:** Integrate the official `Leaflet.markercluster` plugin. + - Currently, viewing an "All My Notes" view with hundreds of notes in the same city creates a messy, overlapping visual experience and impacts browser performance. + - Clustering groups nearby markers into a single circle with a count, automatically expanding when the user clicks or zooms in. + - Implementation requires just adding the CDN links to `index.html` and wrapping the markers in a `L.markerClusterGroup()` inside the `renderMapMarkers` loop. + +## 4. Feature: Sort & Filter Active Notes +- **Status:** **[To Do]** +- **Action Plan:** + - **Low-Hanging Fruit:** Add a simple, fast text input filter floating above the note list in the sidebar whenever a notepad is actively viewed. + - Allows users to instantly search for a specific note's title or content within a massive notepad *without* triggering a slow, global backend search. + - Implementation is 100% frontend logic: listening to the input's `keyup` event and filtering the existing `activeNotes` array before re-rendering the sidebar list. + +## 5. Feature: Custom Marker Colors per Notepad +- **Status:** **[To Do]** +- **Action Plan:** + - Update the `Notepad` database model to include an optional `color` field (defaulting to the standard Leaflet blue). + - Add a simple HTML `` selector to the Create/Edit Notepad modals. + - Update the Leaflet marker icon logic to apply a CSS tint/filter (or use customized SVG icons) matching the notepad's selected color. + - This allows users to visually distinguish between different notes (e.g., "Restaurants" vs "Birding Spots") instantly when looking at the map in mixed views like "All My Notes". diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..4d84b03 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn --worker-tmp-dir /dev/shm --config gunicorn_config.py "app:create_app()" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..256365e --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# SpotNotes + +SpotNotes is a location-based note-taking web application. It allows users to drop notes on an interactive map, organize them into notepads, and optionally share them with the public or specific users. + +## Features + +- **Interactive Map:** View and create notes on a map powered by Leaflet and OpenStreetMap. +- **Location-Based Notes:** Every note is tied to specific geographic coordinates. +- **Notepads:** Organize notes into collections called "Notepads". +- **Privacy & Sharing:** Notepads can be private (only you), public (everyone), or shared via link tokens. +- **User Authentication:** Secure registration and login system. +- **Favorites:** Save and easily access your favorite public notepads. +- **Search & Geocoding:** Search through your notes or use geocoding to find locations worldwide. +- **Admin Dashboard:** Manage users, notepads, and notes. + +## Tech Stack + +- **Backend:** Python, Flask, Flask-SQLAlchemy (with SQLite for development/PostgreSQL for production). +- **Frontend:** HTML5, CSS3 (Vanilla), Vanilla JavaScript, Leaflet.js for maps. +- **Deployment:** Configured for Gunicorn and standard WSGI environments (includes `Procfile` and `gunicorn_config.py`). + +## Project Structure + +``` +spotnotes.app/ +│ +├── app.py # Main application entry point +├── config.py # Configuration settings +├── extensions.py # Flask extensions (SQLAlchemy, CORS) +├── requirements.txt # Python dependencies +│ +├── blueprints/ # Flask Blueprints (Controllers) +│ ├── admin.py # Admin dashboard routes +│ ├── api.py # Main REST API for notes/notepads +│ ├── auth.py # User authentication routes +│ ├── search.py # Search and geocoding logic +│ └── static_routes.py # Serving static HTML pages +│ +├── models/ # SQLAlchemy Database Models +│ ├── banned_ip.py # Security model for IP bans +│ ├── favorite.py # User's favorited notepads +│ ├── note.py # Core note data structure +│ ├── notepad.py # Collections of notes +│ └── user.py # User account data +│ +└── static/ # Frontend Assets + ├── index.html # Main map interface + ├── login.html # Login page + ├── register.html # Registration page + ├── admin.html # Admin interface + └── assets/ + ├── css/ # Modular stylesheets + ├── js/ # Modular vanilla JS scripts + └── images/ # Images and icons +``` + +## Getting Started + +### Prerequisites + +- Python 3.8+ +- pip (Python package installer) + +### Installation + +1. **Clone the repository** (or download the source): + ```bash + git clone + cd spotnotes.app + ``` + +2. **Create a Virtual Environment (Optional but recommended):** + ```bash + python -m venv venv + source venv/bin/activate # On Windows use: venv\Scripts\activate + ``` + +3. **Install Dependencies:** + ```bash + pip install -r requirements.txt + ``` + +4. **Run the Application:** + ```bash + python app.py + ``` + The app will start on `http://127.0.0.1:5000/`. The database (`spotnotes.db`) will be created automatically upon the first run. + +## Planned Features (Roadmap) + +As detailed in the `GEMINI.md` file, the following features are actively planned or in development: + +1. **Advanced Collaboration:** Share notepads with specific users granting explicit Read or Read/Write permissions. +2. **Content Editing:** Ability to edit existing notes and notepads. +3. **Rich Text & Multiline Notes:** Support for multiline text and bulleted lists. +4. **CSV Export:** Export all notes within a notepad to a `.csv` file. +5. **Location-Based Alerts:** Browser notifications when physically approaching a saved note. + +## License + +All rights reserved. diff --git a/app.py b/app.py new file mode 100644 index 0000000..08536d3 --- /dev/null +++ b/app.py @@ -0,0 +1,34 @@ +from flask import Flask +from werkzeug.middleware.proxy_fix import ProxyFix +from config import Config +from extensions import CORS, db +from blueprints import register_blueprints + +app = Flask(__name__, static_folder='static') +app.config.from_object(Config) + +# Fix for Nginx proxying (ensures correct IP addresses) +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + +CORS(app) +db.init_app(app) +register_blueprints(app) + +with app.app_context(): + from models.user import User + from models.notepad import Notepad + from models.favorite import Favorite + from models.note import Note + from models.banned_ip import BannedIP + from models.collaborator import Collaborator + + db.create_all() + + # Create default Global Public Notepad if it doesn't exist + if not Notepad.query.filter_by(name="Global Public Notes").first(): + global_pad = Notepad(name="Global Public Notes", is_public=True) + db.session.add(global_pad) + db.session.commit() + +if __name__ == "__main__": + app.run(debug=True) diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000..b62815e --- /dev/null +++ b/blueprints/__init__.py @@ -0,0 +1,13 @@ +from flask import Flask +from .api import api_bp +from .static_routes import static_bp +from .auth import auth_bp +from .admin import admin_bp +from .search import search_bp + +def register_blueprints(app: Flask): + app.register_blueprint(api_bp) + app.register_blueprint(static_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(search_bp) \ No newline at end of file diff --git a/blueprints/admin.py b/blueprints/admin.py new file mode 100644 index 0000000..7d73794 --- /dev/null +++ b/blueprints/admin.py @@ -0,0 +1,126 @@ +from flask import Blueprint, jsonify, session +from models.user import User +from models.notepad import Notepad +from models.note import Note +from models.banned_ip import BannedIP +from extensions import db + +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') + +def check_admin(): + user_id = session.get('user_id') + if not user_id: + return False + + user = User.query.get(user_id) + return user and user.is_admin + +@admin_bp.before_request +def before_request(): + if not check_admin(): + return jsonify({'error': 'Unauthorized'}), 403 + +@admin_bp.route('/stats', methods=['GET']) +def get_stats(): + return jsonify({ + 'user_count': User.query.count(), + 'notepad_count': Notepad.query.count(), + 'note_count': Note.query.count() + }) + +@admin_bp.route('/users', methods=['GET']) +def get_users(): + users = User.query.all() + user_list = [user.to_dict() for user in users] + + # Add "Unowned" virtual user for managing public/unowned pads + unowned_count = Notepad.query.filter_by(user_id=None).count() + if unowned_count > 0: + user_list.insert(0, { + 'id': 0, + 'email': 'Unowned / Public Notepads', + 'first_name': 'System', + 'last_name': '', + 'last_ip': None, + 'is_admin': False, + 'is_banned': False + }) + + return jsonify(user_list) + +@admin_bp.route('/users//notepads', methods=['GET']) +def get_user_notepads(user_id): + # Handle virtual user ID 0 for unowned notepads + query_id = None if user_id == 0 else user_id + notepads = Notepad.query.filter_by(user_id=query_id).all() + return jsonify([notepad.to_dict() for notepad in notepads]) + +@admin_bp.route('/notepads//notes', methods=['GET']) +def get_notepad_notes(notepad_id): + notepad = Notepad.query.get_or_404(notepad_id) + notes = notepad.notes.all() + return jsonify([note.to_dict() for note in notes]) + +@admin_bp.route('/notes/', methods=['DELETE']) +def delete_note(note_id): + note = Note.query.get_or_404(note_id) + db.session.delete(note) + db.session.commit() + return jsonify({'message': 'Note deleted'}), 200 + +@admin_bp.route('/notepads/', methods=['DELETE']) +def delete_notepad(notepad_id): + notepad = Notepad.query.get_or_404(notepad_id) + # Clean up notes first to prevent FK errors if cascade is missing + for note in notepad.notes: + db.session.delete(note) + from models.favorite import Favorite + Favorite.query.filter_by(notepad_id=notepad.id).delete() + db.session.delete(notepad) + db.session.commit() + return jsonify({'message': 'Notepad deleted'}), 200 + +@admin_bp.route('/users/', methods=['DELETE']) +def delete_user(user_id): + user = User.query.get_or_404(user_id) + + # Clean up user's notepads and notes + notepads = Notepad.query.filter_by(user_id=user.id).all() + from models.favorite import Favorite + for notepad in notepads: + for note in notepad.notes: + db.session.delete(note) + Favorite.query.filter_by(notepad_id=notepad.id).delete() + db.session.delete(notepad) + + Favorite.query.filter_by(user_id=user.id).delete() + db.session.delete(user) + db.session.commit() + return jsonify({'message': 'User deleted'}), 200 + +@admin_bp.route('/users//ban', methods=['POST']) +def ban_user(user_id): + user = User.query.get_or_404(user_id) + user.is_banned = True + db.session.commit() + return jsonify({'message': 'User banned'}), 200 + +@admin_bp.route('/users//unban', methods=['POST']) +def unban_user(user_id): + user = User.query.get_or_404(user_id) + user.is_banned = False + db.session.commit() + return jsonify({'message': 'User unbanned'}), 200 + +@admin_bp.route('/users//ban_ip', methods=['POST']) +def ban_user_ip(user_id): + user = User.query.get_or_404(user_id) + if not user.last_ip: + return jsonify({'error': 'User has no recorded IP'}), 400 + + if not BannedIP.query.filter_by(ip_address=user.last_ip).first(): + banned = BannedIP(ip_address=user.last_ip) + db.session.add(banned) + db.session.commit() + + return jsonify({'message': f'IP {user.last_ip} banned'}), 200 \ No newline at end of file diff --git a/blueprints/api.py b/blueprints/api.py new file mode 100644 index 0000000..f1c6421 --- /dev/null +++ b/blueprints/api.py @@ -0,0 +1,391 @@ +from flask import Blueprint, jsonify, request, session, current_app, Response +from extensions import db +from itsdangerous import URLSafeSerializer +from models.notepad import Notepad +from models.note import Note +from models.collaborator import Collaborator +from models.user import User +from models.favorite import Favorite +import csv +from io import StringIO + +api_bp = Blueprint('api', __name__, url_prefix='/api') + +def get_share_token(item_type, item_id): + s = URLSafeSerializer(current_app.config['SECRET_KEY'], salt='share-link') + return s.dumps({'type': item_type, 'id': item_id}) + +def validate_share_token(token, item_type, item_id): + s = URLSafeSerializer(current_app.config['SECRET_KEY'], salt='share-link') + try: + data = s.loads(token) + return data.get('type') == item_type and data.get('id') == item_id + except: + return False + +def check_pad_read_permission(notepad, user_id, token=None): + is_shared_by_link = token and validate_share_token(token, 'notepad', notepad.id) + if notepad.is_public or is_shared_by_link: + return True + if not user_id: + return False + if notepad.user_id == user_id: + return True + # Check for collaborator (any permission level grants read) + if Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id).first(): + return True + return False + +def check_pad_write_permission(notepad, user_id): + if not user_id: + return False + if notepad.user_id == user_id: + return True + # Check for collaborator with 'write' permission + collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first() + return collab is not None + +@api_bp.route('/notepads', methods=['GET']) +def get_notepads(): + user_id = session.get('user_id') + filter_type = request.args.get('type') + + query = Notepad.query + + if filter_type == 'favorites': + query = Notepad.query.join(Favorite).filter(Favorite.user_id == user_id) if user_id else Notepad.query.filter_by(id=-1) + elif filter_type == 'mine': + query = query.filter_by(user_id=user_id) if user_id else Notepad.query.filter_by(id=-1) + elif filter_type == 'shared': + query = Notepad.query.join(Collaborator).filter(Collaborator.user_id == user_id) if user_id else Notepad.query.filter_by(id=-1) + elif filter_type == 'public': + query = query.filter_by(is_public=True) + else: + # Default behavior (union) if no type specified + if user_id: + query = query.filter((Notepad.is_public == True) | (Notepad.user_id == user_id)) + else: + query = query.filter_by(is_public=True) + + pads = query.order_by(Notepad.name).all() + results = [] + for pad in pads: + p_dict = pad.to_dict() + if user_id: + if pad.user_id == user_id: + p_dict['share_token'] = get_share_token('notepad', pad.id) + p_dict['can_write'] = check_pad_write_permission(pad, user_id) + else: + p_dict['can_write'] = False + results.append(p_dict) + return jsonify(results) + +@api_bp.route('/notepads/', methods=['GET']) +def get_notepad(pad_id): + notepad = Notepad.query.get_or_404(pad_id) + user_id = session.get('user_id') + + token = request.args.get('token') + + if not check_pad_read_permission(notepad, user_id, token): + return jsonify({'error': 'Unauthorized'}), 403 + + p_dict = notepad.to_dict() + p_dict['can_write'] = check_pad_write_permission(notepad, user_id) + if user_id and notepad.user_id == user_id: + p_dict['share_token'] = get_share_token('notepad', pad_id) + return jsonify(p_dict) + +@api_bp.route('/notepads', methods=['POST']) +def create_notepad(): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + new_pad = Notepad(name=data['name'], is_public=data.get('is_public', False), user_id=user_id) + db.session.add(new_pad) + db.session.commit() + return jsonify(new_pad.to_dict()), 201 + +@api_bp.route('/notepads//notes', methods=['GET']) +def get_notes(pad_id): + notepad = Notepad.query.get_or_404(pad_id) + user_id = session.get('user_id') + + token = request.args.get('token') + + if not check_pad_read_permission(notepad, user_id, token): + return jsonify({'error': 'Unauthorized'}), 403 + + notes = notepad.notes.filter_by(is_hidden=False).all() + results = [] + for note in notes: + n_dict = note.to_dict() + if user_id and notepad.user_id == user_id: + n_dict['share_token'] = get_share_token('note', note.id) + results.append(n_dict) + return jsonify(results) + +@api_bp.route('/notes/', methods=['GET']) +def get_note(note_id): + note = Note.query.get_or_404(note_id) + notepad = Notepad.query.get(note.notepad_id) + + user_id = session.get('user_id') + + token = request.args.get('token') + is_shared = token and validate_share_token(token, 'note', note_id) + + if not notepad.is_public and (not user_id or notepad.user_id != user_id) and not is_shared: + return jsonify({'error': 'Unauthorized'}), 403 + + n_dict = note.to_dict() + n_dict['can_write'] = check_pad_write_permission(notepad, user_id) + + pad_dict = notepad.to_dict() + # Redact name if private and not owner + if not notepad.is_public and (not user_id or notepad.user_id != user_id): + pad_dict['name'] = "Private Notepad" + + n_dict['notepad'] = pad_dict + n_dict['owner_id'] = notepad.user_id + n_dict['is_public'] = notepad.is_public + + if user_id and notepad.user_id == user_id: + n_dict['share_token'] = get_share_token('note', note_id) + + return jsonify(n_dict) + +@api_bp.route('/notes/mine', methods=['GET']) +def get_my_all_notes(): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + + notes = Note.query.join(Notepad).filter(Notepad.user_id == user_id, Note.is_hidden == False).all() + results = [] + for note in notes: + n_dict = note.to_dict() + n_dict['notepad'] = note.notepad.to_dict() + n_dict['owner_id'] = user_id + n_dict['is_public'] = note.notepad.is_public + n_dict['share_token'] = get_share_token('note', note.id) + results.append(n_dict) + return jsonify(results) + +@api_bp.route('/notes', methods=['POST']) +def create_note(): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + notepad_id = data.get('notepad_id') + if not notepad_id: + return jsonify({'error': 'notepad_id is required'}), 400 + + notepad = Notepad.query.get_or_404(notepad_id) + + if not check_pad_write_permission(notepad, user_id): + return jsonify({'error': 'Forbidden'}), 403 + + new_note = Note( + title=data.get('title', 'Untitled'), + content=data['content'], + lat=data['lat'], + lng=data['lng'], + notepad_id=notepad_id + ) + db.session.add(new_note) + db.session.commit() + return jsonify(new_note.to_dict()), 201 + +@api_bp.route('/notes/', methods=['PUT']) +def update_note(note_id): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + note = Note.query.get_or_404(note_id) + notepad = Notepad.query.get(note.notepad_id) + if not check_pad_write_permission(notepad, user_id): + return jsonify({'error': 'Forbidden'}), 403 + data = request.get_json() + note.title = data.get('title', note.title) + note.content = data.get('content', note.content) + db.session.commit() + return jsonify(note.to_dict()) + +@api_bp.route('/notepads/', methods=['PUT']) +def update_notepad(pad_id): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + + notepad = Notepad.query.get_or_404(pad_id) + # Only owner can edit notepad metadata + if notepad.user_id != user_id: + return jsonify({'error': 'Forbidden'}), 403 + + data = request.get_json() + notepad.name = data.get('name', notepad.name) + notepad.is_public = data.get('is_public', notepad.is_public) + db.session.commit() + return jsonify(notepad.to_dict()) + +@api_bp.route('/notes//report', methods=['POST']) +def report_note(note_id): + note = Note.query.get_or_404(note_id) + if not note.notepad.is_public: + return jsonify({'error': 'Can only report public notes'}), 400 + note.is_hidden = True + db.session.commit() + return jsonify({'message': 'Note reported'}), 200 + +@api_bp.route('/notepads//favorite', methods=['POST']) +def favorite_notepad(pad_id): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + + existing_favorite = Favorite.query.filter_by(user_id=user_id, notepad_id=pad_id).first() + if existing_favorite: + return jsonify({'message': 'Notepad already favorited'}), 200 + + new_favorite = Favorite(user_id=user_id, notepad_id=pad_id) + db.session.add(new_favorite) + db.session.commit() + return jsonify({'message': 'Notepad favorited'}), 201 + +@api_bp.route('/notepads//unfavorite', methods=['POST']) +def unfavorite_notepad(pad_id): + user_id = session.get('user_id') + favorite = Favorite.query.filter_by(user_id=user_id, notepad_id=pad_id).first() + if favorite: + db.session.delete(favorite) + db.session.commit() + return jsonify({'message': 'Notepad unfavorited'}), 200 + +@api_bp.route('/notepads//collaborators', methods=['GET']) +def get_collaborators(pad_id): + user_id = session.get('user_id') + notepad = Notepad.query.get_or_404(pad_id) + + # Only owner can see collaborators + if notepad.user_id != user_id: + return jsonify({'error': 'Forbidden'}), 403 + + collaborators = Collaborator.query.join(User).filter(Collaborator.notepad_id == pad_id).all() + + results = [] + for collab in collaborators: + results.append({ + 'user_id': collab.user.id, + 'email': collab.user.email, + 'permission': collab.permission + }) + return jsonify(results) + +@api_bp.route('/notepads//collaborators', methods=['POST']) +def add_collaborator(pad_id): + user_id = session.get('user_id') + notepad = Notepad.query.get_or_404(pad_id) + + if notepad.user_id != user_id: + return jsonify({'error': 'Forbidden'}), 403 + + data = request.get_json() + email = data.get('email') + permission = data.get('permission') + + if not email or not permission or permission not in ['read', 'write']: + return jsonify({'error': 'Email and permission (read/write) are required'}), 400 + + collaborator_user = User.query.filter_by(email=email).first() + if not collaborator_user: + return jsonify({'error': 'User not found'}), 404 + + if collaborator_user.id == user_id: + return jsonify({'error': 'Cannot add yourself as a collaborator'}), 400 + + existing_collab = Collaborator.query.filter_by(notepad_id=pad_id, user_id=collaborator_user.id).first() + if existing_collab: + existing_collab.permission = permission + else: + new_collab = Collaborator(notepad_id=pad_id, user_id=collaborator_user.id, permission=permission) + db.session.add(new_collab) + + db.session.commit() + return jsonify({'message': 'Collaborator added/updated successfully'}), 200 + +@api_bp.route('/notepads//collaborators/', methods=['DELETE']) +def remove_collaborator(pad_id, collaborator_user_id): + user_id = session.get('user_id') + notepad = Notepad.query.get_or_404(pad_id) + + if notepad.user_id != user_id: + return jsonify({'error': 'Forbidden'}), 403 + + collab_to_remove = Collaborator.query.filter_by(notepad_id=pad_id, user_id=collaborator_user_id).first() + if not collab_to_remove: + return jsonify({'error': 'Collaborator not found'}), 404 + + db.session.delete(collab_to_remove) + db.session.commit() + return jsonify({'message': 'Collaborator removed'}), 200 + +@api_bp.route('/notepads//export', methods=['GET']) +def export_notepad(pad_id): + notepad = Notepad.query.get_or_404(pad_id) + user_id = session.get('user_id') + + token = request.args.get('token') + if not check_pad_read_permission(notepad, user_id, token): + return jsonify({'error': 'Unauthorized'}), 403 + + si = StringIO() + cw = csv.writer(si) + cw.writerow(['Note ID', 'Title', 'Content', 'Latitude', 'Longitude', 'Created At']) + + for note in notepad.notes.filter_by(is_hidden=False): + cw.writerow([note.id, note.title, note.content, note.lat, note.lng, note.created_at]) + + output = si.getvalue() + return Response( + output, + mimetype="text/csv", + headers={"Content-Disposition": f"attachment;filename=notepad_{pad_id}_export.csv"} + ) + +@api_bp.route('/notepads/', methods=['DELETE']) +def delete_notepad(pad_id): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + + notepad = Notepad.query.get_or_404(pad_id) + if notepad.user_id != user_id: + return jsonify({'error': 'Forbidden'}), 403 + + for note in notepad.notes: + db.session.delete(note) + Favorite.query.filter_by(notepad_id=notepad.id).delete() + db.session.delete(notepad) + db.session.commit() + return jsonify({'message': 'Notepad deleted'}), 200 + +@api_bp.route('/notes/', methods=['DELETE']) +def delete_note(note_id): + user_id = session.get('user_id') + if not user_id: + return jsonify({'error': 'Unauthorized'}), 401 + + note = Note.query.get_or_404(note_id) + notepad = Notepad.query.get(note.notepad_id) + + if not check_pad_write_permission(notepad, user_id): + return jsonify({'error': 'Forbidden'}), 403 + + db.session.delete(note) + db.session.commit() + return jsonify({'message': 'Note deleted'}), 200 \ No newline at end of file diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000..2711281 --- /dev/null +++ b/blueprints/auth.py @@ -0,0 +1,72 @@ +from flask import Blueprint, request, jsonify, session +from extensions import db +from models.user import User +from models.banned_ip import BannedIP + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') + +@auth_bp.route('/register', methods=['POST']) +def register(): + data = request.get_json() + email = data.get('email') + ip_address = request.remote_addr + + if BannedIP.query.filter_by(ip_address=ip_address).first(): + return jsonify({'error': 'Registration blocked from this IP address.'}), 403 + + password = data.get('password') + first_name = data.get('first_name') + last_name = data.get('last_name') + + if not email or not password or not first_name or not last_name: + return jsonify({'error': 'All fields are required'}), 400 + + if User.query.filter_by(email=email).first(): + return jsonify({'error': 'Email already exists'}), 400 + + new_user = User(email=email, first_name=first_name, last_name=last_name, last_ip=ip_address) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + return jsonify({'message': 'User registered successfully'}), 201 + +@auth_bp.route('/login', methods=['POST']) +def login(): + data = request.get_json() + email = data.get('email') + password = data.get('password') + ip_address = request.remote_addr + + if BannedIP.query.filter_by(ip_address=ip_address).first(): + return jsonify({'error': 'Login blocked from this IP address.'}), 403 + + user = User.query.filter_by(email=email).first() + + if user and user.check_password(password): + if user.is_banned: + return jsonify({'error': 'This account has been banned.'}), 403 + + user.last_ip = ip_address + db.session.commit() + session['user_id'] = user.id + return jsonify({'message': 'Login successful', 'user': user.to_dict()}), 200 + + return jsonify({'error': 'Invalid username or password'}), 401 + +@auth_bp.route('/logout', methods=['POST']) +def logout(): + session.pop('user_id', None) + return jsonify({'message': 'Logged out'}), 200 + +@auth_bp.route('/me', methods=['GET']) +def get_current_user(): + user_id = session.get('user_id') + if not user_id: + return jsonify({'authenticated': False}), 200 + + user = User.query.get(user_id) + if user: + return jsonify({'authenticated': True, 'user': user.to_dict()}), 200 + return jsonify({'authenticated': False}), 200 \ No newline at end of file diff --git a/blueprints/search.py b/blueprints/search.py new file mode 100644 index 0000000..f4f54cc --- /dev/null +++ b/blueprints/search.py @@ -0,0 +1,79 @@ +from flask import Blueprint, request, jsonify, session +from sqlalchemy import or_ +from extensions import db +from models.note import Note +from models.notepad import Notepad +from models.favorite import Favorite +from .api import check_pad_write_permission +import urllib.request +import urllib.parse +import json + +search_bp = Blueprint('search', __name__) + +@search_bp.route('/api/search') +def search_notes(): + query = request.args.get('q', '') + if not query: + return jsonify([]) + + # Join Note and Notepad to filter by permissions + # Explicitly join on the foreign key to ensure correct results + sql_query = db.session.query(Note, Notepad).join(Notepad, Note.notepad_id == Notepad.id).filter( + or_( + Note.title.ilike(f'%{query}%'), + Note.content.ilike(f'%{query}%') + ) + ) + + user_id = session.get('user_id') + if user_id: + sql_query = sql_query.filter( + or_( + Notepad.is_public == True, + Notepad.user_id == user_id + ) + ) + else: + sql_query = sql_query.filter(Notepad.is_public == True) + + results = sql_query.all() + + # Get user favorites if logged in + fav_ids = set() + if user_id: + favs = Favorite.query.filter_by(user_id=user_id).all() + fav_ids = {f.notepad_id for f in favs} + + # Construct response with extra info for frontend (owner_id, is_public) + output = [] + for note, pad in results: + n_dict = note.to_dict() + p_dict = pad.to_dict() + p_dict['is_favorite'] = pad.id in fav_ids + p_dict['can_write'] = check_pad_write_permission(pad, user_id) + + n_dict['notepad'] = p_dict + n_dict['owner_id'] = pad.user_id # Keep for map marker compatibility + n_dict['is_public'] = pad.is_public # Keep for map marker compatibility + output.append(n_dict) + + return jsonify(output) + +@search_bp.route('/api/geocode') +def geocode(): + q = request.args.get('q', '') + if not q: + return jsonify([]) + + try: + # Proxy request to Nominatim with required User-Agent + url = f"https://nominatim.openstreetmap.org/search?format=json&q={urllib.parse.quote(q)}" + req = urllib.request.Request(url, headers={'User-Agent': 'SpotNotesApp/1.0'}) + + with urllib.request.urlopen(req) as response: + data = json.loads(response.read().decode()) + return jsonify(data) + except Exception as e: + print(f"Geocode error: {e}") + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/blueprints/static_routes.py b/blueprints/static_routes.py new file mode 100644 index 0000000..739a4d4 --- /dev/null +++ b/blueprints/static_routes.py @@ -0,0 +1,11 @@ +from flask import Blueprint, current_app + +static_bp = Blueprint('static_routes', __name__) + +@static_bp.route('/') +def index(): + return current_app.send_static_file('index.html') + +@static_bp.route('/') +def serve_static(path): + return current_app.send_static_file(path) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..72a205d --- /dev/null +++ b/config.py @@ -0,0 +1,9 @@ +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-very-secret' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'spotnotes.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False \ No newline at end of file diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..d227d1d --- /dev/null +++ b/extensions.py @@ -0,0 +1,5 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_cors import CORS + +db = SQLAlchemy() +cors = CORS() \ No newline at end of file diff --git a/gunicorn_config.py b/gunicorn_config.py new file mode 100644 index 0000000..30e6708 --- /dev/null +++ b/gunicorn_config.py @@ -0,0 +1,2 @@ +bind = "0.0.0.0:8081" +workers = 2 diff --git a/models/banned_ip.py b/models/banned_ip.py new file mode 100644 index 0000000..d72829a --- /dev/null +++ b/models/banned_ip.py @@ -0,0 +1,7 @@ +from extensions import db +from datetime import datetime + +class BannedIP(db.Model): + id = db.Column(db.Integer, primary_key=True) + ip_address = db.Column(db.String(45), unique=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/models/collaborator.py b/models/collaborator.py new file mode 100644 index 0000000..d13ff0b --- /dev/null +++ b/models/collaborator.py @@ -0,0 +1,25 @@ +from extensions import db +from datetime import datetime + +class Collaborator(db.Model): + id = db.Column(db.Integer, primary_key=True) + notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + permission = db.Column(db.String(20), default='read', nullable=False) # 'read' or 'write' + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref=db.backref('collaborations', cascade='all, delete-orphan', lazy='dynamic')) + notepad = db.relationship('Notepad', backref=db.backref('collaborators', cascade='all, delete-orphan', lazy='dynamic')) + + __table_args__ = ( + db.UniqueConstraint('notepad_id', 'user_id', name='unique_collaborator'), + ) + + def to_dict(self): + return { + 'id': self.id, + 'notepad_id': self.notepad_id, + 'user_id': self.user_id, + 'user_email': self.user.email if self.user else None, + 'permission': self.permission + } \ No newline at end of file diff --git a/models/favorite.py b/models/favorite.py new file mode 100644 index 0000000..d0afb29 --- /dev/null +++ b/models/favorite.py @@ -0,0 +1,10 @@ +from extensions import db + +class Favorite(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id'), nullable=False) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'notepad_id', name='unique_favorite'), + ) \ No newline at end of file diff --git a/models/note.py b/models/note.py new file mode 100644 index 0000000..aa9dd44 --- /dev/null +++ b/models/note.py @@ -0,0 +1,22 @@ +from extensions import db +from datetime import datetime + +class Note(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False, default="Untitled") + content = db.Column(db.Text, nullable=False) + lat = db.Column(db.Float, nullable=False) + lng = db.Column(db.Float, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + is_hidden = db.Column(db.Boolean, default=False) + notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id'), nullable=False) + + def to_dict(self): + return { + 'id': self.id, + 'title': self.title, + 'content': self.content, + 'lat': self.lat, + 'lng': self.lng, + 'created_at': self.created_at.isoformat() + } \ No newline at end of file diff --git a/models/notepad.py b/models/notepad.py new file mode 100644 index 0000000..44f3562 --- /dev/null +++ b/models/notepad.py @@ -0,0 +1,21 @@ +from extensions import db +from datetime import datetime + + +class Notepad(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + is_public = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + + notes = db.relationship('Note', backref='notepad', lazy='dynamic', cascade="all, delete-orphan") + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'is_public': self.is_public, + 'owner_id': self.user_id, + 'note_count': self.notes.filter_by(is_hidden=False).count() + } \ No newline at end of file diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..db24207 --- /dev/null +++ b/models/user.py @@ -0,0 +1,29 @@ +from extensions import db +from werkzeug.security import generate_password_hash, check_password_hash + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False) + first_name = db.Column(db.String(50), nullable=False) + last_name = db.Column(db.String(50), nullable=False) + password_hash = db.Column(db.String(256)) + is_admin = db.Column(db.Boolean, default=False) + is_banned = db.Column(db.Boolean, default=False) + last_ip = db.Column(db.String(45)) + + 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, + 'email': self.email, + 'first_name': self.first_name, + 'last_name': self.last_name, + 'is_admin': self.is_admin, + 'is_banned': self.is_banned, + 'last_ip': self.last_ip + } \ No newline at end of file diff --git a/notes/birding.csv b/notes/birding.csv new file mode 100644 index 0000000..27bf7b6 --- /dev/null +++ b/notes/birding.csv @@ -0,0 +1,51 @@ +title,content,lat,lng +Magee Marsh,The "Warbler Capital of the World" offers legendary close-up views of colorful migrants.,41.6117,-83.1864 +Bosque del Apache NWR,Tens of thousands of Sandhill Cranes and Snow Geese create a stunning winter spectacle.,33.7997,-106.8906 +Cape May,A critical stopover point where thousands of hawks and songbirds congregate during fall.,38.9319,-74.9077 +Everglades National Park,This massive wetland is a paradise for spotting Roseate Spoonbills and Wood Storks.,25.2866,-80.8987 +High Island,Famous for "fallouts" where exhausted migratory songbirds land by the hundreds.,29.5608,-94.3894 +Point Reyes National Seashore,Over 490 bird species have been recorded at this diverse coastal headland.,38.0801,-122.9575 +Big Bend National Park,The only place in the United States where you can find the Colima Warbler.,29.2498,-103.2502 +Monterey Bay,A premier pelagic birding destination for Albatrosses, Shearwaters, and Jaegers.,36.6002,-121.8947 +Madera Canyon,A "sky island" famous for Elegant Trogons and over a dozen hummingbird species.,31.7230,-110.8800 +Sapsucker Woods,The home of the Cornell Lab of Ornithology features serene trails and rare species.,42.4795,-76.4513 +Aransas NWR,The primary wintering ground for the endangered and majestic Whooping Crane.,28.3139,-96.8040 +Sax-Zim Bog,A world-famous winter destination for boreal owls and northern finches.,47.1691,-92.7441 +J.N. "Ding" Darling NWR,Mangrove forests filled with Yellow-crowned Night-Herons and Roseate Spoonbills.,26.4468,-82.1122 +Bear River Migratory Bird Refuge,One of the best places in the West for American Avocets and Black-necked Stilts.,41.5126,-112.2681 +Chincoteague NWR,A diverse Atlantic coast refuge famous for shorebirds and Snowy Egrets.,37.9150,-75.3621 +Central Park,An urban oasis that acts as a "migrant trap" for dozens of warbler species.,40.7851,-73.9683 +Platte River Valley,The spring staging ground for nearly one million Sandhill Cranes.,40.7300,-98.4000 +Cave Creek Canyon,Spectacular cliffs in the Chiricahuas home to many Mexican specialties.,31.8967,-109.1719 +Laguna Atascosa NWR,A South Texas hotspot for Altamira Orioles and Aplomado Falcons.,26.2289,-97.3542 +Bombay Hook NWR,Expansive tidal salt marshes that attract massive numbers of shorebirds.,39.2559,-75.4678 +Mount Desert Island,Diverse habitats in Acadia National Park for Atlantic Puffins and Spruce Grouse.,44.3386,-68.2733 +Grand Isle,A vital first stop for Neotropical migrants crossing the Gulf of Mexico.,29.2366,-89.9873 +Klamath Basin NWR,The largest wintering population of Bald Eagles in the lower 48 states.,41.9200,-121.5700 +Santa Ana NWR,The "crown jewel" of the national wildlife refuge system for South Texas birds.,26.0700,-98.1400 +Hawk Mountain Sanctuary,The first refuge in the world dedicated specifically to birds of prey.,40.6416,-75.9866 +Dry Tortugas National Park,Isolated islands that serve as the only U.S. nesting site for Sooty Terns.,24.6285,-82.8733 +Lower Rio Grande Valley,A subtropical birding paradise with Green Jays and Great Kiskadees.,26.1000,-97.8000 +Malheur NWR,A high-desert oasis that is a massive draw for Trumpeter Swans and cranes.,43.2644,-118.8456 +Fort De Soto Park,Consistently ranked as one of the best spots in Florida for shorebird diversity.,27.6143,-82.7169 +Dauphin Island,A primary landfall site for migratory birds arriving in the spring.,30.2505,-88.1026 +Yellowstone National Park,High-altitude birding for Harlequin Ducks and American Dippers.,44.4280,-110.5885 +Olympic National Park,Rugged coastline and alpine forests home to Sooty Grouse and Marbled Murrelets.,47.8021,-123.6044 +Edwin B. Forsythe NWR,Managed impoundments that are ideal for viewing tens of thousands of waterfowl.,39.4633,-74.4447 +Patagonia-Sonoita Creek,One of the few places in the U.S. to see the Thick-billed Kingbird.,31.5422,-110.7600 +Great Smoky Mountains,Diverse elevations support everything from Wild Turkeys to Blackburnian Warblers.,35.6118,-83.4895 +Tule Lake NWR,A major stop on the Pacific Flyway for White-fronted Geese and Eared Grebes.,41.8800,-121.4600 +Merritt Island NWR,Surrounds the Kennedy Space Center and hosts thousands of wintering ducks.,28.6415,-80.6860 +Anahuac NWR,Coastal prairies and marshes famous for sightings of the elusive Yellow Rail.,29.6105,-94.4304 +Kodiak NWR,A rugged Alaskan refuge where you can find Tufted Puffins and Emperor Geese.,57.4800,-153.5000 +San Juan Islands NWR,Pristine marine habitats for Pigeon Guillemots and Rhinoceros Auklets.,48.5300,-123.0000 +Seney NWR,Large pools in Michigan's Upper Peninsula provide habitat for Common Loons.,46.2872,-85.9392 +Okefenokee NWR,Mysterious swamp habitat home to Sandhill Cranes and Prothonotary Warblers.,30.8400,-82.3400 +Gunnison Basin,The best (and only) place to find the rare Gunnison Sage-Grouse.,38.5458,-106.9253 +Glacier National Park,Alpine vistas hosting White-tailed Ptarmigan and Clark's Nutcrackers.,48.7596,-113.7870 +Snake River Canyon,The highest density of nesting raptors in all of North America.,43.3000,-115.8000 +Montrose Point,A world-class urban birding site on the Lake Michigan shoreline.,41.9642,-87.6375 +Waikamoi Preserve,A high-altitude rainforest that is the last stronghold for several Hawaiian honeycreepers.,20.7800,-156.2300 +Brazos Bend State Park,An inland Texas site known for its diversity of herons and songbirds.,29.3712,-95.5966 +Jekyll Island,A Georgia barrier island that is an excellent site for wintering shorebirds.,31.0700,-81.4200 +Pawnee National Grasslands,Wide-open prairies home to McCown's Longspurs and Mountain Plovers.,40.7833,-104.1167 \ No newline at end of file diff --git a/notes/conspiracy_locations.csv b/notes/conspiracy_locations.csv new file mode 100644 index 0000000..8ecc0ab --- /dev/null +++ b/notes/conspiracy_locations.csv @@ -0,0 +1,51 @@ +title,content,lat,lng +Area 51,Highly classified Air Force facility long associated with UFO and alien cover-ups.,37.2343,-115.8108 +Denver International Airport,"Subject of theories involving underground bunkers, New World Order murals, and gargoyles.",39.8561,-104.6737 +Dulce Base,Alleged secret underground laboratory where humans and aliens collaborate on experiments.,36.9395,-106.9986 +The Greenbrier Bunker,Once-secret massive underground facility designed to house Congress in the event of nuclear war.,37.7854,-80.3083 +The Georgia Guidestones Site,The site where granite monuments with 'commandments' for the New World Order stood until 2022.,34.2324,-82.8946 +Plum Island Animal Disease Center,Often linked to theories about the origin of Lyme disease and secret bio-warfare testing.,41.1736,-72.1864 +The HAARP Facility,A research site often accused of being a secret weather-control or mind-control weapon.,62.3933,-145.15 +Mount Weather Emergency Operations Center,The real-world 'safe house' for high-ranking officials during national disasters.,39.0631,-77.8892 +Montauk Project Site (Camp Hero),Alleged site of secret government projects involving time travel and psychological warfare.,41.0628,-71.8744 +The Bohemian Grove,A private campground where world leaders gather for secretive rituals in the redwoods.,38.48,-123.0019 +S-4 Facility,Alleged secret site near Papoose Lake rumored to house recovered extraterrestrial craft.,37.0863,-115.8208 +Winchester Mystery House,Built continuously to confuse the spirits of those killed by Winchester rifles.,37.3184,-121.9511 +Dealey Plaza,Site of the JFK assassination and the source of endless 'grassy knoll' theories.,32.779,-96.8082 +The Octagon House,Rumored to have secret tunnels and connections to early government occultism.,38.8995,-77.0403 +The Pentagon,"Central hub of US defense, often center-stage in massive structural and strategic theories.",38.8719,-77.0563 +Point Pleasant (Mothman Site),Location of numerous sightings of the Mothman before the Silver Bridge collapse.,38.8445,-82.1371 +Roswell UFO Crash Site,The epicenter of modern UFO conspiracy theories involving a 1947 crash.,33.3944,-104.523 +Coral Castle,Built by one man who supposedly used magnetism and anti-gravity to move massive stones.,25.5003,-80.4442 +Skinwalker Ranch,"A property famous for alleged paranormal activity, UFOs, and shape-shifters.",40.2526,-109.8872 +Brown Mountain Lights Overlook,Site of mysterious ghost lights that have defied scientific explanation for a century.,35.845,-81.745 +Marfa Lights Viewing Area,Atmospheric lights that have spawned theories ranging from ghosts to alien scouts.,30.2764,-103.8831 +The Integration,A structure built in the desert intended to act as a rejuvenation machine and time travel portal.,34.2986,-116.4036 +Teotihuacan,Ancient city often cited by ancient astronaut theorists for its planetary alignment.,19.6925,-98.8437 +San Luis Valley,A 'paranormal hotspot' known for cattle mutilations and high UFO traffic.,37.7492,-105.85 +Mount Shasta,"A volcanic peak rumored to hide the underground city of Telos, home to Lemurians.",41.4092,-122.1949 +The Stanley Hotel,Famous haunted hotel that inspired 'The Shining' and is center to psychic theories.,40.3833,-105.5189 +Bridgewater Triangle,"A region in Massachusetts known for cryptids, cult activity, and UFOs.",41.936,-71.026 +Bennington Triangle,Area of mysterious disappearances that some link to supernatural vortexes.,42.8781,-73.1968 +The Superstition Mountains,Home of the Lost Dutchman's Gold Mine and legends of underground gateways.,33.45,-111.35 +Bigfoot Discovery Museum,The headquarters for researchers tracking the legendary forest cryptid.,37.0505,-122.0725 +Sedona Vortexes,Sites believed to emit swirling centers of energy that affect the human spirit.,34.8697,-111.761 +International Cryptozoology Museum,Preserves evidence and artifacts from sightings of unproven animals.,43.6565,-70.2558 +Project Sign/Grudge Site (Wright-Patterson),Base rumored to be the true location where Roswell debris was stored.,39.8136,-84.0494 +Cheyenne Mountain Complex,Underground bunker that serves as the command center for NORAD.,38.7442,-104.8461 +Raven Rock Mountain Complex,The 'underground Pentagon' designed to sustain government after an attack.,39.7333,-77.4167 +Site R,Alternative name for the secret Raven Rock facility in Pennsylvania.,39.734,-77.417 +The Archuleta Mesa,High-altitude site above Dulce rumored to be an alien landing strip.,36.994,-106.942 +Blackwood Chasm,Alleged site of unexplained seismic activity and 'hollow earth' rumors.,35.1234,-112.5678 +The Great Dismal Swamp,Site of many 'maroon' colony legends and strange atmospheric phenomena.,36.6384,-76.4789 +Oak Island Money Pit,Site of a 200-year-old treasure hunt involving elaborate traps and theories.,44.5133,-64.2953 +Bermuda Triangle Tip (Miami),Eastern edge of the zone where ships and planes famously vanish.,25.7617,-80.1918 +Point Dume,Rumored underwater base entrance for UFOs coming from the Pacific.,34.0012,-118.8068 +Yakima UFO Hotspot,A location on the Yakima Indian Reservation with high frequency of 'orb' sightings.,46.2307,-120.6552 +Groom Lake,The dry lake bed adjacent to Area 51 used for secret test flights.,37.2758,-115.7989 +Tonopah Test Range,Site used for testing stealth technology and captured foreign aircraft.,37.795,-116.7722 +Dugway Proving Ground,"Often called 'Area 52', known for testing chemical and biological weapons.",40.2,-113.15 +The Winchester Vault,A specific locked room where Sarah Winchester allegedly held séances.,37.3185,-121.951 +The Dakota Building,Historical NYC residence associated with cult theories and the occult.,40.776,-73.9763 +Granite Mountain Records Vault,Massive Mormon genealogy vault built into a mountain for protection.,40.5728,-111.7711 +The Sphinx (Giza),Ancient monument often the subject of 'Hall of Records' underground theories.,29.9753,31.1376 diff --git a/notes/general_public.csv b/notes/general_public.csv new file mode 100644 index 0000000..585faad --- /dev/null +++ b/notes/general_public.csv @@ -0,0 +1,101 @@ +title,content,lat,lng +The Center of the Universe,An acoustic anomaly in Tulsa where you can shout and only you hear the echo.,36.1553,-95.9931 +The World's Tallest Thermometer,"A 134-foot tall reminder that Baker, California, is basically the surface of the sun.",35.2661,-116.0725 +Carhenge,A faithful replica of Stonehenge made entirely of vintage American automobiles.,42.1422,-102.8581 +The Fremont Troll,A massive stone troll living under a bridge that is currently eating a real VW Beetle.,47.651,-122.3473 +The Leaning Tower of Niles,A half-size replica of the Tower of Pisa that was built to hide a water tank.,42.0233,-87.7964 +Unclaimed Baggage Center,The only place on Earth where you can buy someone else's lost vacation dreams.,34.8697,-86.0028 +The Maine Ridge Runner,A tribute to the man who lived in the woods and stole only library books.,44.5497,-69.7797 +Cadillac Ranch Graffiti,A public note to the world where everyone is encouraged to bring spray paint.,35.1872,-101.987 +The Muffler Man,A giant fiberglass Paul Bunyan who lost his axe and now just looks confused.,35.5122,-98.9819 +World's Largest Ball of Twine,Built by one man who just didn't know when to stop winding.,39.4842,-97.6475 +The Mushroom Houses,Architectural notes in stone that look like they were designed by a Smurf.,45.3167,-85.2583 +Berkeley's Fish House,A house shaped like a giant fish because why live in a square box?,40.015,-105.2705 +The Prada Marfa,"A permanent ""store"" in the middle of the desert that will never actually open.",30.6035,-104.5185 +The Beer Can House,"A retirement project involving 50,000 flattened cans and a lot of patience.",29.7711,-95.4294 +The Republic of Molossia,"A sovereign ""micronation"" in Nevada that is currently at war with East Germany.",39.3233,-119.5397 +The Oregon Vortex,"A ""forbidden"" patch of land where brooms stand up and people look shorter.",42.4936,-123.0844 +Dinosaur Park,A collection of green concrete monsters that haven't aged a day since 1936.,44.0761,-103.2436 +The Paper House,"A home built out of 100,000 newspapers that has survived since 1922.",42.6681,-70.6289 +The Jolly Green Giant,A 55-foot tall statue that makes every visitor feel like a side dish of peas.,43.6483,-94.1039 +World's Largest Chest of Drawers,"A 38-foot tall dresser that serves as a literal ""note"" on the local furniture industry.",35.975,-80.0069 +Lucy the Elephant,A six-story high wooden elephant that used to be a real estate office.,39.3225,-74.4533 +Desert Christ Park,A hillside covered in giant statues waiting for a movie that never started.,34.1197,-116.4447 +The Gum Wall,"A colorful, sticky, and slightly gross public mosaic of chewed-up history.",47.6083,-122.34 +Ave Maria Grotto,A tiny world of 125 miniature stone shrines built by a hunchbacked monk.,34.1758,-86.8294 +"Hole N"" The Rock","A 5,000-square-foot home blasted into a solid cliff face by one very determined man.",38.39,-109.455 +The Spite House,A very narrow home built specifically to block a neighbor's view of the street.,42.3781,-71.1167 +Coral Castle,"A monument built by a 100-pound man using ""the secrets of the pyramids.""",25.5006,-80.4442 +The Haines Shoe House,A literal giant shoe that you can actually sleep in.,39.9928,-76.6347 +The Toilet Seat Art Museum,"A collection of over 1,000 lids proving that anything can be a canvas.",35.2014,-101.8317 +Enchanted Highway,A stretch of road featuring a 110-foot tall family made of scrap metal.,46.4719,-102.5589 +The Peachoid,A water tower that looks suspiciously like a giant bottom from the highway.,35.0808,-81.6739 +The Big Duck,A poultry-shaped building designed specifically to sell duck eggs.,40.8233,-72.7153 +The Hat Museum,A tribute to the days when nobody left the house without a topper.,45.5122,-122.6758 +Jimmy Carter's Peanut,A giant smiling peanut statue that bears an uncanny resemblance to the president.,32.0436,-84.3917 +The UFO Watchtower,A platform in the desert where people wait for a ride to the stars.,37.935,-105.8753 +The Longaberger Basket,A seven-story office building that looks exactly like a picnic basket.,40.0636,-82.3458 +Vollis Simpson Whirligig Park,"A field of massive, colorful kinetic sculptures made from farm parts.",35.7311,-77.9125 +The Bone Museum,An Oklahoma City gem where the skeletons literally tell the story.,35.3942,-97.4933 +The Wigwam Village No. 2,"A 1930s hotel where you can tell people you ""slept in a teepee.""",37.1472,-85.9189 +The Blue Hole,A natural artesian spring in the desert that stays 62 degrees all year.,34.9439,-104.6736 +Salem Witch Trials Memorial,"A somber note in stone to those who were ""not actually witches.""",42.5211,-70.8911 +The Evolution of the O,A statue at OU that students touch for luck before finals.,35.2089,-97.4456 +Old North Tower,A historic building on UCO's campus that is a landmark for every Edmond local.,35.6558,-97.4725 +World's Smallest Police Station,A repurposed phone booth that was used by the local force until 1990.,29.8136,-84.6589 +The Egg City,A tribute to the once-thriving poultry industry that now looks like an alien base.,34.2814,-118.8833 +Sleepy Hollow Headless Horseman,A bridge where people still look over their shoulders for a pumpkin-headed rider.,41.0903,-73.8617 +The International UFO Museum,"The only place where ""I saw a weather balloon"" is an insult.",33.3944,-104.5231 +Marvin's Marvelous Mechanical Museum,A chaotic collection of vintage coin-op machines and weird neon signs.,42.5286,-83.3528 +The Winchester House Mystery,A home with stairs that lead nowhere and doors that open to drops.,37.3184,-121.9367 +The Neon Museum,"The ""Boneyard"" where retired Las Vegas signs go to spend their golden years.",36.1769,-115.1356 +Lucy the Elephant,A six-story elephant built in 1881 to sell real estate and survived several hurricanes.,39.3233,-74.4533 +Prada Marfa,A permanently closed luxury boutique in the middle of the Texas desert that is actually an art installation.,30.6035,-104.5185 +The Corn Palace,A multipurpose arena decorated annually with murals made entirely of corn and other grains.,43.7142,-98.0253 +Wall Drug Store,A massive tourist stop that started by offering free ice water and now takes up an entire city block.,43.9928,-102.2414 +The Thing?,"A roadside mystery in Arizona that you have to pay a few dollars to see, hidden in a series of sheds.",32.0942,-110.3019 +Salvation Mountain,"A massive colorful art installation made of adobe and straw, covered in thousands of gallons of paint.",33.2538,-115.4731 +Seven Magic Mountains,"A desert art installation featuring seven towers of colorful, stacked boulders.",35.8383,-115.2708 +Musical Highway,A stretch of Route 66 that plays 'America the Beautiful' if you drive exactly 45 mph.,35.0744,-106.4322 +Carving of Crazy Horse,A mountain monument under construction since 1948 that will eventually be the world's largest sculpture.,43.8367,-103.6244 +Bottle Tree Ranch,A forest of 'trees' made from glass bottles and scrap metal along old Route 66.,34.6908,-117.3374 +Wigwam Motel,A classic motel where you sleep in individual concrete teepees.,34.9022,-110.1581 +London Bridge,"The actual bridge from London, dismantled and moved to the Arizona desert in 1968.",34.4717,-114.3472 +Meteor Crater,"The best-preserved meteorite impact site on Earth, nearly one mile across.",35.0272,-111.0225 +Bishop Castle,A massive stone castle built by one man over 60 years using local rocks.,38.0614,-105.0942 +The Stanley Hotel,The inspiration for Stephen King's 'The Shining' and a hotspot for ghost hunters.,40.3833,-105.5189 +Meow Wolf Santa Fe,An immersive art experience that starts in a Victorian house and leads to psychedelic dimensions.,35.6548,-105.9967 +Cadillac Ranch,"Ten Cadillacs buried nose-first in the ground, inviting visitors to leave their mark with spray paint.",35.1872,-101.987 +Hole N' The Rock,"A 5,000 square foot home carved directly into a massive sandstone rock.",38.3912,-109.4567 +Enchanted Highway,A collection of the world's largest scrap metal sculptures along a 32-mile stretch of road.,46.4674,-102.5583 +Cabazon Dinosaurs,Giant roadside dinosaurs featured in the movie 'Pee-wee's Big Adventure'.,33.92,-116.7725 +Winslow Corner,"A park dedicated to the Eagles' song 'Take It Easy', complete with a flatbed Ford.",35.0242,-110.6975 +Hoover Dam,A massive concrete arch-gravity dam in the Black Canyon of the Colorado River.,36.0162,-114.7372 +Four Corners Monument,The only place in the US where you can stand in four states at the same time.,36.9991,-109.0452 +Shiprock,"A towering volcanic rock formation that rises 1,500 feet above the desert floor.",36.6872,-108.8364 +Very Large Array,A massive radio astronomy observatory consisting of 27 giant dish antennas.,34.079,-107.6184 +Rio Grande Gorge Bridge,"The tenth highest bridge in the United States, offering dizzying views of the river below.",36.4764,-105.733 +Garden of the Gods,A park of towering red sandstone rock formations against a backdrop of Pikes Peak.,38.8785,-104.8806 +Royal Gorge Bridge,"One of the world's highest suspension bridges, hanging 955 feet above the Arkansas River.",38.4614,-105.3253 +Casa Bonita,"A legendary pink Mexican restaurant with indoor waterfalls, cliff divers, and puppet shows.",39.7419,-105.0708 +Coral Castle,An oolite limestone structure built by one man who claimed to know the secrets of the pyramids.,25.5003,-80.4442 +Ames Pyramid,A 60-foot tall granite pyramid built to honor the brothers who financed the Union Pacific Railroad.,41.1314,-105.3975 +Market Theater Gum Wall,"A brick wall covered entirely in used chewing gum, considered a local landmark.",47.6084,-122.3404 +The Big Basket,A seven-story office building shaped exactly like a Longaberger picnic basket.,40.0633,-82.3464 +Carhenge,A replica of England's Stonehenge formed from vintage American automobiles.,42.1422,-102.8581 +Winchester Mystery House,A massive Victorian mansion built continuously for 38 years to appease spirits.,37.3184,-121.9511 +Dinosaur Park,A hilltop park featuring seven giant concrete dinosaur sculptures built during the Great Depression.,44.0789,-103.2458 +International UFO Museum,A museum dedicated to the 1947 Roswell UFO incident and all things extraterrestrial.,33.3944,-104.523 +Gatorland,"The 'Alligator Capital of the World', featuring thousands of gators and crocodiles.",28.3556,-81.3364 +The Blue Hole,A sapphire-blue artesian spring in the middle of the New Mexico desert.,34.9439,-104.6736 +Dinosaur Kingdom II,A bizarre theme park where Union soldiers fight dinosaurs in the Civil War.,37.6631,-79.4883 +Desert of Maine,A 40-acre patch of glacial silt that looks exactly like a desert in the middle of a forest.,43.8586,-70.1283 +World's Largest Chest of Drawers,"An 80-foot tall building shaped like a dresser, complete with giant socks hanging out.",35.9681,-80.0101 +Ava Maria Grotto,A park containing 125 miniature replicas of famous religious structures from around the world.,34.1764,-86.8331 +The Unclaimed Baggage Center,The only store in the world that buys and sells lost luggage from airlines.,34.8697,-86.0028 +Voodoo Doughnut,A quirky doughnut shop known for its eccentric decor and unconventional toppings.,45.5227,-122.6731 +The Heidelberg Project,An open-air art environment in Detroit that transformed a neighborhood into a massive gallery.,42.3584,-83.0214 +Wall Drug,The legendary drug store that grew from free ice water into a world-famous attraction.,43.9928,-102.2414 +The Salt Palace,A salt-covered building in Utah where you can literally lick the walls.,40.7667,-111.895 +Paper House,A house built entirely out of newspapers by a mechanical engineer in 1922.,42.6622,-70.6289 +Dog Bark Park Inn,"A bed and breakfast shaped like a giant beagle, where guests sleep inside the dog.",46.0583,-116.4447 diff --git a/notes/haunted-oklahoma.csv b/notes/haunted-oklahoma.csv new file mode 100644 index 0000000..eda9db4 --- /dev/null +++ b/notes/haunted-oklahoma.csv @@ -0,0 +1,51 @@ +title,content,lat,lng +Skirvin Hotel,A maid named Effie and her baby are said to haunt the 10th floor.,35.4697,-97.5144 +Cain's Ballroom,The ghost of Bob Wills is rumored to play his fiddle late at night.,36.1610,-95.9918 +Stone Lion Inn,A young girl named Augusta is often heard running through the halls.,35.8797,-97.4257 +Dead Woman's Crossing,A headless woman searches for her child near the old bridge site.,35.5667,-98.6500 +Fort Washita,The spirit of Aunt Jane is seen wandering the grounds in a 19th-century dress.,34.1030,-96.5505 +Belvidere Mansion,The original owner died before completion and still roams the master suite.,36.3150,-95.6078 +Cherokee Strip Museum,Ghostly hospital patients from its former life are seen in the windows.,36.7570,-98.6660 +Guthrie Masonic Boys Home,Orphan spirits are said to play in the halls of this former home.,35.8647,-97.4111 +Blue Belle Saloon,The ghost of a murdered woman named Claudia haunts the cellar.,35.8778,-97.4261 +Overholser Mansion,The Overholser family spirits are seen looking out from the windows.,35.4856,-97.5283 +Constantine Theater,A ghostly woman in a Victorian dress is seen sitting in the balcony.,36.6661,-96.3400 +Fort Reno,Soldiers from the frontier and POWs are heard marching on the grounds.,35.5451,-98.0267 +Tulsa Theater,A stagehand who died in an accident is said to haunt the backstage.,36.1583,-95.9936 +Labadie Mansion,A tragic fire left the spirits of a family trapped in the ruins.,36.7112,-95.9525 +Haskell University (Old Site),Crying children are heard near the sites of the former boarding schools.,35.8236,-95.3405 +Bird Creek School,A spectral teacher is still seen writing on the chalkboard after dark.,36.6961,-96.3389 +Kitchen Lake,The "Witch of Kitchen Lake" is said to chase off late-night visitors.,35.3444,-97.3911 +Parallel Forest,Trees planted in perfect rows create a disorienting haunt for hikers.,34.7297,-98.6744 +Spook Light,A mysterious orb of light has bounced down this road for over 100 years.,36.9414,-94.6361 +Gilcrease Museum,Thomas Gilcrease is seen wandering his favorite garden paths.,36.1751,-95.9996 +Picher Ghost Town,The toxic remains of this town are haunted by the echoes of those left behind.,36.9839,-94.8291 +St. Vincent's Asylum,Tormented spirits from the hospital's dark past roam the abandoned wings.,35.4725,-97.5501 +Langston's Western Wear,Two sisters who died in a fire haunt the upstairs dance floor area.,35.4489,-97.5456 +The Purple Church,Rumors of occult rituals have left a dark energy at these ruins.,35.9189,-97.4183 +Sacred Heart Mission,The charred ruins are haunted by those lost in a devastating 1901 fire.,35.1436,-97.0544 +Hex House,A real-life tale of psychological torture left a permanent stain here.,36.1556,-95.9889 +Griffin Memorial Hospital,Shadow figures are frequently reported in the older hospital tunnels.,35.2189,-97.4236 +Cry Baby Bridge (Moore),The sounds of a crying infant are heard coming from beneath the span.,35.3392,-97.4589 +Timber Ridge Cemetery,A ghostly boy on a bicycle is seen along the road at night.,36.1744,-95.7336 +Governor's Mansion,Former residents are seen in the halls of this historic residence.,35.4925,-97.5033 +Mohawk Park,A "Deer Lady" is said to lure people into the deep woods here.,36.2167,-95.9000 +Poncan Theatre,A ghost named "Pete" is known to play pranks on the projectionist.,36.7031,-97.0850 +Wheelock Academy,The "Ghost Maiden" is seen walking the grounds of the girls' school.,33.9939,-94.9961 +Ingalls Ghost Town,Outlaw spirits from the Doolin-Dalton gang shootout still linger.,36.1044,-96.8833 +Murrell Home,Musical instruments are heard playing in empty rooms of the mansion.,35.8572,-94.9603 +Oklahoma State University,A ghost named "Old Red" is seen in the halls of the library.,36.1214,-97.0678 +University of Oklahoma,The spirit of a roller-skating boy haunts the North Oval.,35.2089,-97.4456 +Santa Fe Depot (Guthrie),The sounds of a steam engine are heard when no train is near.,35.8778,-97.4303 +Hillside Mission Cemetery,Native American spirits are seen protecting the old mission grounds.,36.1558,-96.2239 +Broken Arrow Elks Lodge,A mysterious man in a tuxedo is seen in the ballroom mirrors.,36.0508,-95.7908 +Central State Hospital,Unexplained screams are heard coming from the old morgue area.,35.2194,-97.4253 +Pawnee Bill Ranch,The ghost of May Lillie is seen in her former bedroom.,36.3339,-96.8086 +Kendall's Restaurant,A young boy's ghost likes to play with toys in the dining area.,35.1400,-97.3919 +Saline Courthouse,The old lawmen never truly left this historic Cherokee site.,36.2081,-95.1158 +Jack Brown Home,Lights flicker and whispers are heard in this old Tahlequah house.,35.9133,-94.9700 +Hotel Vinita,A man in a 1920s suit is seen walking through the ballroom walls.,36.6439,-95.1556 +Frank and Lola's,A female spirit named Estelle is heard calling out from the kitchen.,36.7481,-95.9764 +Pink House Restaurant,A protective female ghost is known to move furniture and slam doors.,35.9583,-95.3772 +Elgin Cemetery,Strange lights and shadow figures follow visitors through the gates.,34.7814,-98.2911 +Boggy Depot,A Confederate soldier is seen standing guard near the old cemetery.,34.3514,-96.3119 \ No newline at end of file diff --git a/notes/haunted-usa.csv b/notes/haunted-usa.csv new file mode 100644 index 0000000..de3280b --- /dev/null +++ b/notes/haunted-usa.csv @@ -0,0 +1,51 @@ +title,content,lat,lng +Eastern State Penitentiary,The "Slick Willie" spirit is often seen in the shadows of the crumbling cellblocks.,39.9683,-75.1727 +The Stanley Hotel,The sounds of a phantom piano playing in the ballroom often wake guests.,40.3833,-105.5189 +Gettysburg Battlefield,Soldiers in tattered uniforms are frequently seen reenacting their final moments.,39.8142,-77.2375 +The Whaley House,The ghost of "Yankee Jim" Robinson is said to haunt the staircase where he died.,32.7523,-117.1947 +Waverly Hills Sanatorium,The "Creeper" is a shadow figure known to crawl along the ceiling of the tunnels.,38.1256,-85.8414 +LaLaurie Mansion,Victims of the infamous Madame LaLaurie are heard screaming from the attic.,29.9615,-90.0620 +The Myrtles Plantation,Chloe a former slave is often photographed peering out from behind columns.,30.7951,-91.2882 +Trans-Allegheny Lunatic Asylum,A ghostly girl named Lily is known to follow visitors and play with toys.,39.0392,-80.4682 +The Queen Mary,A "lady in white" wanders the salon and ghosts are heard splashing in the pool.,33.7526,-118.1903 +Villisca Axe Murder House,The spirits of the Moore children are still heard laughing and crying.,40.9299,-94.9740 +Alcatraz Island,Cell block D is said to have a "cold spot" where an unseen entity chokes inmates.,37.8267,-122.4230 +St. Augustine Lighthouse,Two young sisters who drowned during construction are seen playing on the steps.,29.8860,-81.2884 +The Mount,Edith Wharton's former home is haunted by figures in period clothing.,42.3486,-73.3150 +Winchester Mystery House,The heiress built endless hallways to confuse the spirits of those killed by her rifles.,37.3184,-121.9367 +Lizzie Borden House,The spirits of the elder Bordens are said to still linger in the guest rooms.,41.7011,-71.1558 +Crescent Hotel,A Victorian ghost named Michael is known to play pranks in Room 218.,36.4086,-93.7364 +Sossaman Ferry Road,A spectral hitchhiker is seen waiting for a ride that never comes.,33.4000,-111.6667 +The Lemp Mansion,Three members of the Lemp family committed suicide and are seen in the halls.,38.5908,-90.2245 +Bachelor's Grove Cemetery,A phantom farmhouse appears and disappears in the middle of the woods.,41.6306,-87.7697 +Rolling Hills Asylum,A massive shadow man named Roy stalks the hallway of the second floor.,42.8988,-78.1360 +The Driskill Hotel,The spirit of a young girl playing with a ball is heard on the grand staircase.,30.2681,-97.7405 +Old Charleston Jail,Infamous serial killer Lavinia Fisher is said to haunt her former cell.,32.7758,-79.9372 +Dock Street Theatre,A ghost named Nettie is seen gliding across the stage in a red dress.,32.7781,-79.9303 +Fort Mifflin,The "Screaming Lady" is heard wailing from the casemates of the fort.,39.8856,-75.2125 +Trans-Allegheny Asylum,Patients who were mistreated are seen wandering the massive stone corridors.,39.0392,-80.4682 +Moundsville Penitentiary,The "Shadow Man" is a dark entity seen lurking near the execution room.,39.9142,-80.7428 +Moon River Brewing Co.,A violent spirit named "Toby" is known to push people in the basement.,32.0809,-81.0924 +Sorrel-Weed House,The spirits of two women who died tragic deaths haunt the courtyard.,32.0734,-81.0950 +Bodie Ghost Town,The ghosts of miners protect the town and curse those who take artifacts.,38.2122,-119.0125 +Old Louisville,A Victorian gentleman is often seen walking his dog along the gas-lit streets.,38.2291,-85.7602 +The Omni Homestead,A jilted bride is said to roam the 14th floor looking for her groom.,37.9958,-79.8286 +The Red Onion Saloon,A ghost named Lydia is known to leave the scent of perfume in the air.,59.4539,-135.3136 +Bobby Mackey's Music World,A "portal to hell" in the basement is said to cause physical scratches.,39.0667,-84.5000 +Pennhurst Asylum,The mistreatment of patients has left behind a heavy dark energy and screams.,40.1834,-75.5562 +Sloss Furnaces,A cruel foreman named "Slag" is said to haunt the catwalks.,33.5206,-86.7908 +The Bell Witch Cave,An entity tormented the Bell family and is said to still inhabit the cave.,36.5894,-87.2158 +Merchant's House Museum,The Tredwell family never left their 19th-century Manhattan home.,40.7281,-73.9926 +The Congress Plaza Hotel,"Peg Leg Johnny" is a spirit that wanders the halls of the south tower.,41.8753,-87.6247 +The Marshall House,The hotel served as a hospital and guests report seeing phantom amputees.,32.0798,-81.0906 +The Grove,The "Lady in White" is seen walking the gardens of this Jefferson estate.,32.7606,-94.3411 +Ohio State Reformatory,The spirit of the warden's wife is often smelled as rose perfume.,40.7836,-82.5022 +Fort Delaware,Confederate prisoners are seen marching through the dungeon-like cells.,39.5889,-75.5686 +Emily's Bridge,A vengeful spirit named Emily is said to scratch cars that cross at night.,44.4375,-72.4844 +Poinsett Bridge,Strange lights and figure-like mists are seen under the stone arch.,35.1292,-82.3386 +Salem Witch House,The energy of the 1692 witch trials still hangs heavy in this home.,42.5215,-70.8996 +The Octagon House,The spirits of two daughters who fell down the stairs still linger.,38.8947,-77.0406 +Pike Place Market,A ghost named "Arthur" is known to browse the stalls after hours.,47.6097,-122.3422 +Bushwick Inlet Park,A ghostly sea captain is seen looking out over the East River.,40.7222,-73.9619 +The Rengstorff House,A woman in a white dress is seen staring out at the bay.,37.4339,-122.0911 +The Menger Hotel,The ghost of Teddy Roosevelt has been seen having a drink at the bar.,29.4248,-98.4862 \ No newline at end of file diff --git a/notes/route66.csv b/notes/route66.csv new file mode 100644 index 0000000..5649e38 --- /dev/null +++ b/notes/route66.csv @@ -0,0 +1,51 @@ +title,content,lat,lng +Route 66 Begin Sign,The symbolic start of the Mother Road located on Adams Street in downtown Chicago.,41.8794,-87.6245 +Lou Mitchell's,Known as the "first stop" on the route serving breakfast since before the road opened.,41.8784,-87.6395 +Gemini Giant,A 30-foot-tall "Muffler Man" in an astronaut suit named for the Gemini space program.,41.3105,-88.1478 +Ambler's Texaco Gas Station,The longest operating gas station on Route 66 now serves as a visitor center.,41.0924,-88.4271 +Standard Oil Gas Station,An iconic eight-sided "cottage-style" station that served travelers in the 1930s.,41.0044,-88.5233 +Chain of Rocks Bridge,Famous for its 22-degree bend this bridge carried travelers over the Mississippi River.,38.7601,-90.1764 +Gateway Arch,The 630-foot monument to westward expansion is a primary landmark for Missouri travelers.,38.6247,-90.1848 +Meramec Caverns,Advertised for decades on barns across the country as a legendary outlaw hideout.,38.2431,-91.0903 +Wagon Wheel Motel,The oldest continuously operating motel on the route with original stone cottages.,38.0011,-91.3914 +Gay Parita Sinclair Station,A lovingly recreated 1930s gas station that is a favorite for photo ops.,37.2642,-93.7142 +66 Drive-In Theatre,A classic neon-lit outdoor cinema that has been showing movies since 1949.,37.1856,-94.3353 +Cars on the Route,A restored station featuring the tow truck that inspired the character Mater.,37.0753,-94.6361 +Rainbow Bridge,The last remaining Marsh Arch bridge on Route 66 built in 1923.,37.0672,-94.7075 +Blue Whale of Catoosa,A whimsical waterfront landmark built as an anniversary gift in the early 70s.,36.1932,-95.7333 +The Blue Dome,A 1924 Art Deco service station that once offered 24-hour service to travelers.,36.1539,-95.9861 +Rock Cafe,A historic eatery built from local stone whose owner inspired Sally in the movie Cars.,35.7533,-96.6542 +Arcadia Round Barn,A restored 1898 wooden barn that is a feat of local engineering and design.,35.6608,-97.3239 +Pops 66 Soda Ranch,A modern landmark featuring a 66-foot tall neon soda bottle and over 700 sodas.,35.6606,-97.3353 +Milk Bottle Grocery,A tiny triangular building topped with a giant metal milk bottle.,35.4925,-97.5303 +Lake Overholser Bridge,A critical 1924 link for early motorists crossing the North Canadian River.,35.4800,-97.6694 +Lucille's Service Station,A classic "porch-style" station that served travelers for over 60 years.,35.5317,-98.5306 +Route 66 Museum Clinton,An immersive museum that takes visitors through the history of the road by decade.,35.5122,-98.9819 +U-Drop Inn,A stunning Art Deco masterpiece that served as both a café and a gas station.,35.2153,-100.2483 +Leaning Tower of Texas,A tilted water tower intended as a clever marketing gimmick to draw travelers in.,35.2014,-101.1128 +Big Texan Steak Ranch,Home of the free 72-ounce steak challenge if you can finish it in an hour.,35.1914,-101.7606 +Cadillac Ranch,An art installation featuring 10 vintage Cadillacs buried nose-first in the dirt.,35.1872,-101.9870 +Midpoint Cafe,The geographical halfway point between Chicago and Santa Monica.,35.0394,-102.6664 +Blue Swallow Motel,Famous for its stunning neon sign and garages attached to every room.,35.1764,-103.7153 +Tee Pee Curios,A whimsical shop shaped like a tipi that has sold souvenirs since the 1940s.,35.1717,-103.7317 +Route 66 Auto Museum,A high-quality collection of vintage cars and memorabilia in Santa Rosa.,34.9383,-104.6881 +Central Avenue,The longest urban stretch of the original route running through Albuquerque.,35.0844,-106.6504 +KiMo Theatre,A beautiful "Pueblo Deco" theater that blends Native American and Art Deco styles.,35.0844,-106.6522 +El Rancho Hotel,The "Home of the Movie Stars" who filmed Westerns in the nearby desert.,35.5261,-108.7214 +Yellowhorse Trading Post,A gateway to the Navajo Nation set against massive red sandstone cliffs.,35.3533,-109.0556 +Jack Rabbit Trading Post,Famous for its "HERE IT IS" sign and the giant fiberglass rabbit out front.,34.9800,-110.3347 +Wigwam Motel,One of only two remaining motels on Route 66 where you can sleep in a tipi.,34.9039,-110.1586 +Painted Desert Inn,A National Historic Landmark built from petrified wood and native stone.,35.0833,-109.7833 +Standin' on the Corner Park,Commemorates the famous lyrics from the Eagles song "Take It Easy".,35.0211,-110.6975 +La Posada Hotel,A restored 1929 Harvey House hotel that was a favorite of celebrities.,35.0242,-110.6931 +Meteor Crater,The world's best-preserved meteorite impact site located just off the route.,35.0272,-111.0225 +Museum Club,A former roadhouse and taxidermy shop that became a famous country music venue.,35.2047,-111.5997 +Seligman Route 66 Shield,A large painted highway shield on the pavement in the "Birthplace of Historic Route 66".,35.3267,-112.8711 +Delgadillo's Snow Cap,A quirky drive-in famous for its practical jokes and vintage "shocker" cars.,35.3261,-112.8756 +Hackberry General Store,A "museum" of Route 66 kitsch and vintage gas pumps in the desert.,35.3661,-113.7225 +Oatman Ghost Town,A former gold mining camp famous for wild burros that roam the streets.,35.0264,-114.3831 +Elmer's Bottle Tree Ranch,A unique forest of art installations made entirely from vintage glass bottles.,34.6908,-117.3381 +Wigwam Motel San Bernardino,The second "village" of concrete tipis still welcoming guests in California.,34.1069,-117.3456 +Cucamonga Service Station,A restored 1915 station that represents the earliest days of the route.,34.1067,-117.5939 +Santa Monica Pier,The official symbolic "End of the Trail" where the route meets the Pacific.,34.0101,-118.4961 +End of the Trail Sign,The most photographed sign on the route marking the 2,448-mile journey's end.,34.0086,-118.4983 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dccaabe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask +requests +Flask-Cors +gunicorn +Flask-SQLAlchemy +python-dotenv \ No newline at end of file diff --git a/seed_notes.py b/seed_notes.py new file mode 100644 index 0000000..fef9001 --- /dev/null +++ b/seed_notes.py @@ -0,0 +1,80 @@ +import csv +import sys +import os +from app import app +from extensions import db +from models.notepad import Notepad +from models.note import Note +from models.user import User + +def seed_from_csv(csv_file, notepad_name, is_public=True, owner_email=None): + """ + Reads a CSV file and populates a notepad with notes. + CSV Format: title, content, lat, lng + """ + if not os.path.exists(csv_file): + print(f"Error: File '{csv_file}' not found.") + return + + with app.app_context(): + # 1. Resolve Owner (Optional) + user_id = None + if owner_email: + user = User.query.filter_by(email=owner_email).first() + if user: + user_id = user.id + else: + print(f"Warning: User with email '{owner_email}' not found. Notepad will be unowned.") + + # 2. Find or Create Notepad + notepad = Notepad.query.filter_by(name=notepad_name).first() + if not notepad: + print(f"Creating new notepad: '{notepad_name}'") + notepad = Notepad(name=notepad_name, is_public=is_public, user_id=user_id) + db.session.add(notepad) + db.session.commit() + else: + print(f"Using existing notepad: '{notepad.name}' (ID: {notepad.id})") + + # 3. Read CSV and Create Notes + try: + with open(csv_file, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + count = 0 + for row in reader: + # Basic validation + if not all(k in row for k in ('title', 'content', 'lat', 'lng')): + print(f"Skipping invalid row: {row}") + continue + + try: + note = Note( + title=row['title'], + content=row['content'], + lat=float(row['lat']), + lng=float(row['lng']), + notepad_id=notepad.id + ) + db.session.add(note) + count += 1 + except ValueError: + print(f"Invalid coordinates in row: {row['title']}") + continue + + db.session.commit() + print(f"Success! Added {count} notes to '{notepad_name}'.") + + except Exception as e: + print(f"An error occurred: {e}") + db.session.rollback() + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: python seed_notes.py [owner_email]") + sys.exit(1) + + csv_path = sys.argv[1] + pad_name = sys.argv[2] + owner = sys.argv[3] if len(sys.argv) > 3 else None + + seed_from_csv(csv_path, pad_name, is_public=True, owner_email=owner) \ No newline at end of file diff --git a/static/about.html b/static/about.html new file mode 100644 index 0000000..7863678 --- /dev/null +++ b/static/about.html @@ -0,0 +1,88 @@ + + + + + + About - SpotNotes.app + + + + + + + + + +
+ SpotNotes.app + +

About SpotNotes.app

+

+ SpotNotes is a unique location-based digital notepad designed to help you connect with the world around you. + Unlike traditional note-taking apps, SpotNotes allows you to pin your thoughts, reminders, and discoveries to specific coordinates on a map. +

+ +

+ Whether you are marking a hidden hiking trail, leaving a review for a local coffee shop, or just jotting down a memory at a specific spot, + SpotNotes gives your notes a place in the real world. +

+ +
+ +

Key Features

+
+
+

📍 Location-Based Notes

+

Create notes that are tied to exact map coordinates. View them in context with the surrounding area.

+
+
+

🌍 Public & Private Notepads

+

Organize your notes into notepads. Keep them private for personal use or make them public to share with the community.

+
+
+

❤️ Favorites

+

Found a notepad you like? Add it to your favorites for quick and easy access later.

+
+
+

🛡️ Community Driven

+

Help keep the map clean by reporting inappropriate content. We believe in a safe and useful environment for everyone.

+
+
+ +
+ +

Frequently Asked Questions

+ +
+

Is SpotNotes free to use?

+

Yes, SpotNotes is completely free. You can create unlimited notes and notepads.

+
+ +
+

Do I need an account to view notes?

+

No, you can explore public notes and the Global Public Feed without an account. However, you will need to register to create your own notes.

+
+ +
+

Can I collaborate on notepads?

+

Currently, public notepads can be viewed and edited by anyone.

+
+ + +
+ + + + \ No newline at end of file diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..21366fe --- /dev/null +++ b/static/admin.html @@ -0,0 +1,48 @@ + + + + + + Admin Panel - SpotNotes.app + + + + + + +
+

Admin Panel

+ + +
+ +
+ +
+

+ Users ▼ +

+ +
+

Loading users...

+
+
+ +
+

Notepads ▼

+
+

Select a user to see their notepads.

+
+
+ +
+

Notes ▼

+
+

Select a notepad to see its notes.

+
+
+
+ + + + \ No newline at end of file diff --git a/static/assets/css/admin.css b/static/assets/css/admin.css new file mode 100644 index 0000000..122f50c --- /dev/null +++ b/static/assets/css/admin.css @@ -0,0 +1,102 @@ +/* Admin-specific styles */ +.admin-button { + background-color: #dc3545; /* Reddish for danger actions */ + color: white; + border: none; + padding: 5px 10px; + margin: 5px; + border-radius: 4px; + cursor: pointer; +} + +.admin-button:hover { + background-color: #c82333; +} + +.user-item { + padding: 10px; + border-bottom: 1px solid #eee; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; +} + +.admin-section { + margin-bottom: 20px; + border: 1px solid #ddd; + border-radius: 5px; + overflow: hidden; + background: #fff; +} + +.section-title { + background-color: #f8f9fa; + padding: 10px 15px; + margin: 0; + font-size: 1.1rem; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; +} + +.section-title:hover { + background-color: #e9ecef; +} + +.section-content { + padding: 15px; + display: block; /* Default open */ +} + +.section-content.collapsed { + display: none; +} + +/* Stats Dashboard */ +.stats-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.stat-card { + background: #fff; + border: 1px solid #ddd; + border-radius: 5px; + padding: 20px; + text-align: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.stat-card h3 { + margin: 0; + font-size: 2rem; + color: #007bff; +} + +.stat-card p { + margin: 5px 0 0; + color: #666; + text-transform: uppercase; + font-size: 0.8rem; + font-weight: bold; +} + +/* Fix for layout clipping when content is tall */ +body.page-body { + align-items: flex-start; +} + +.content-container { + margin-top: 50px; + margin-bottom: 50px; +} + +/* Scrollable lists to prevent page from expanding too much */ +.section-content { + max-height: 60vh; + overflow-y: auto; +} \ No newline at end of file diff --git a/static/assets/css/auth.css b/static/assets/css/auth.css new file mode 100644 index 0000000..645356d --- /dev/null +++ b/static/assets/css/auth.css @@ -0,0 +1,37 @@ +/* Auth & Content Containers */ +.auth-container, .content-container { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + width: 100%; + margin: 20px; +} + +.auth-container { max-width: 400px; } +.content-container { max-width: 800px; margin-top: 20px; } + +/* Forms */ +.form-group { margin-bottom: 1rem; } + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #333; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 16px; /* Mobile friendly */ +} + +.links { margin-top: 1rem; text-align: center; font-size: 0.9rem; } +.links a { color: #007bff; text-decoration: none; } +.links a:hover { text-decoration: underline; } + +/* Ensure buttons on content pages are white */ +.content-container .btn { color: white; } \ No newline at end of file diff --git a/static/assets/css/base.css b/static/assets/css/base.css new file mode 100644 index 0000000..ec290c0 --- /dev/null +++ b/static/assets/css/base.css @@ -0,0 +1,40 @@ +/* Reset and Base Styles */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body, html { + height: 100%; + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + overflow: hidden; +} + +body.page-body { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + background: #f4f4f4; + overflow: auto; +} + +/* Generic Components */ +.btn { + width: 100%; + padding: 0.75rem; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: opacity 0.2s; +} +.btn:hover { opacity: 0.9; } +.btn-primary { background: #007bff; } +.btn-success { background: #28a745; } +.btn-secondary { background: #6c757d; margin-top: 10px; } + +.hidden { display: none !important; } \ No newline at end of file diff --git a/static/assets/css/map.css b/static/assets/css/map.css new file mode 100644 index 0000000..b696f4d --- /dev/null +++ b/static/assets/css/map.css @@ -0,0 +1,99 @@ +/* Map Container */ +#map { + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} + +/* Map Crosshair */ +#map-crosshair { + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 900; +} +#map-crosshair::before, #map-crosshair::after { + content: ''; + position: absolute; + background: rgba(0,0,0,0.6); +} +#map-crosshair::before { top: 9px; left: 0; width: 20px; height: 2px; } +#map-crosshair::after { top: 0; left: 9px; width: 2px; height: 20px; } + +/* Map Note Card (Zoomed In) */ +.map-note-card { + background: white; + padding: 8px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + width: 200px; + font-size: 0.85rem; + border: 1px solid #ddd; + white-space: normal; + user-select: none; +} + +/* Move Leaflet controls down so they aren't covered by the navbar */ +.leaflet-top { + top: 50px; /* Matches navbar height */ +} + +@media (max-width: 600px) { + .leaflet-top { + top: 110px; /* Matches mobile navbar height */ + } +} + +/* Note Buttons */ +.btn-note-share { + background-color: #6bbbf7; /* Blue */ + color: black; + border: none; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + margin-top: 5px; + margin-right: 5px; +} + +.btn-note-report { + background-color: #ffcccc; /* Light red */ + color: black; + border: none; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + margin-top: 5px; +} + +.btn-note-delete { + background-color: #ffcccc; /* Light red */ + color: black; + border: none; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + margin-top: 5px; +} + +.btn-note-edit { + background-color: #cce5ff; /* Light blue */ + color: black; + border: none; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + margin-top: 5px; + margin-right: 5px; +} \ No newline at end of file diff --git a/static/assets/css/modals.css b/static/assets/css/modals.css new file mode 100644 index 0000000..4910623 --- /dev/null +++ b/static/assets/css/modals.css @@ -0,0 +1,33 @@ +/* Modals */ +.modal { + position: fixed; + top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0,0,0,0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; + backdrop-filter: blur(2px); +} + +.modal.hidden { display: none; } + +.modal-content { + background: white; + padding: 25px; + border-radius: 8px; + width: 90%; + max-width: 400px; + position: relative; + box-shadow: 0 4px 15px rgba(0,0,0,0.2); +} + +.close-modal { + position: absolute; + top: 10px; right: 15px; + font-size: 1.5rem; + cursor: pointer; + color: #aaa; + transition: color 0.2s; +} +.close-modal:hover { color: #333; } \ No newline at end of file diff --git a/static/assets/css/navbar.css b/static/assets/css/navbar.css new file mode 100644 index 0000000..721ea02 --- /dev/null +++ b/static/assets/css/navbar.css @@ -0,0 +1,94 @@ +/* Top Navigation Bar */ +.navbar { + position: relative; + top: 0; + left: 0; + width: 100%; + height: 50px; + background-color: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(5px); + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 15px; + z-index: 1000; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + flex-shrink: 0 +} + +.brand { + font-weight: bold; + font-size: 1.2rem; + color: #333; + text-decoration: none; + flex: 1; + display: flex; + justify-content: flex-start; +} + +.nav-links { + flex: 1; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 15px; +} + +.nav-links a { + text-decoration: none; + color: #555; + font-size: 0.9rem; + transition: color 0.2s; + white-space: nowrap; +} + +.nav-links a:hover { color: #000; } + +/* Search Bar in Navbar */ +.nav-search { + flex: 0 1 400px; + margin: 0 15px; + display: flex; + justify-content: center; +} +.nav-search input { + width: 100%; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 4px 0 0 4px; + outline: none; + font-size: 16px; /* Prevents zoom on iOS */ +} +.nav-search button { + padding: 5px 12px; + border: 1px solid #ccc; + border-left: none; + background: #f8f9fa; + border-radius: 0 4px 4px 0; + cursor: pointer; +} +.nav-search button:hover { background: #e2e6ea; } + +/* Mobile Navbar */ +@media (max-width: 600px) { + .navbar { + flex-wrap: wrap; + height: auto; + padding: 10px; + gap: 10px; + } + .brand { + flex: 1; + order: 1; + } + .nav-links { + flex: initial; + order: 2; + } + .nav-search { + flex: 1 1 100%; + order: 3; + margin: 0; + max-width: none; + } +} \ No newline at end of file diff --git a/static/assets/css/sidebar.css b/static/assets/css/sidebar.css new file mode 100644 index 0000000..96f8463 --- /dev/null +++ b/static/assets/css/sidebar.css @@ -0,0 +1,167 @@ +/* Right Collapsible Sidebar */ +.sidebar { + position: absolute; + top: 50px; /* Below navbar */ + right: 0; + bottom: 0; + width: 320px; + background-color: white; + z-index: 1000; + box-shadow: -2px 0 5px rgba(0,0,0,0.1); + transition: transform 0.3s ease-in-out; + display: flex; + flex-direction: column; +} + +.sidebar.collapsed { + transform: translateX(320px); +} + +/* Toggle Button attached to sidebar */ +.sidebar-toggle { + position: absolute; + left: -40px; + top: 10px; + width: 40px; + height: 40px; + background-color: white; + border: none; + border-radius: 5px 0 0 5px; + box-shadow: -2px 0 5px rgba(0,0,0,0.1); + cursor: pointer; + font-weight: bold; + color: #555; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; +} + +.sidebar-content { + padding: 0; + overflow: hidden; + flex: 1; + display: flex; + flex-direction: column; +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + border-bottom: 1px solid #eee; + background: #fff; + flex-shrink: 0; +} + +#sidebar-body { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.btn-new-pad { + background-color: #007bff; + color: white; + border: none; + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-center-location { + width: calc(100% - 30px); + margin: 15px; + padding: 10px; + background-color: #17a2b8; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + flex-shrink: 0; +} +.btn-center-location:hover { background-color: #138496; } + +.btn-add-note { + background-color: #28a745; + color: white; + border: none; + padding: 5px 10px; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; +} +.btn-add-note:hover { background-color: #218838; } + +/* Sidebar Lists & Items */ +.list-section { border-bottom: 1px solid #f0f0f0; } + +.list-section-header, +.btn-toggle-section { + width: 100%; + text-align: left; + background: #fff; + border: none; + padding: 12px 15px; + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0; + transition: background-color 0.2s; +} +.list-section-header:hover, +.btn-toggle-section:hover { + background-color: #f8f9fa; + color: #333; +} + +.notepad-item, .sidebar-note-item { + padding: 10px 15px; + border-bottom: 1px solid #eee; + cursor: pointer; +} +.notepad-item:hover, .sidebar-note-item:hover { + background-color: #fafafa; +} + +/* Collaborator List in Share Modal */ +.collaborator-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; +} +.collaborator-item:last-child { + border-bottom: none; +} +.collaborator-item button { + background: none; + border: none; + color: #dc3545; + cursor: pointer; + font-size: 0.8rem; +} + +/* Mobile Sidebar */ +@media (max-width: 600px) { + .sidebar { + width: 85%; + max-width: 350px; + top: 110px; /* Adjust for taller mobile navbar */ + } + .sidebar.collapsed { + transform: translateX(100%); + } +} \ No newline at end of file diff --git a/static/assets/css/styles.css b/static/assets/css/styles.css new file mode 100644 index 0000000..6896780 --- /dev/null +++ b/static/assets/css/styles.css @@ -0,0 +1,339 @@ +/* Reset and Base Styles */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body, html { + height: 100%; + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + overflow: hidden; /* Prevent scrollbars, map handles panning */ +} + +/* Map Container */ +#map { + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} + +/* Map Crosshair */ +#map-crosshair { + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 900; +} +#map-crosshair::before, #map-crosshair::after { + content: ''; + position: absolute; + background: rgba(0,0,0,0.6); +} +#map-crosshair::before { top: 9px; left: 0; width: 20px; height: 2px; } +#map-crosshair::after { top: 0; left: 9px; width: 2px; height: 20px; } + +/* Top Navigation Bar */ +.navbar { + position: relative; + top: 0; + left: 0; + width: 100%; + height: 40px; + background-color: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(5px); + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 20px; + z-index: 1000; /* Above map */ + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.brand { + font-weight: bold; + font-size: 1.2rem; + color: #333; + text-decoration: none; +} + +.nav-links a { + text-decoration: none; + color: #555; + margin-left: 20px; + font-size: 0.9rem; + transition: color 0.2s; +} + +.nav-links a:hover { + color: #000; +} + +/* Search Bar in Navbar */ +.nav-search { + flex: 1; + margin: 0 20px; + display: flex; + max-width: 400px; +} +.nav-search input { + width: 100%; + padding: 5px 10px; + border: 1px solid #ccc; + border-radius: 4px 0 0 4px; + outline: none; +} +.nav-search button { + padding: 5px 10px; + border: 1px solid #ccc; + border-left: none; + background: #f8f9fa; + border-radius: 0 4px 4px 0; + cursor: pointer; +} +.nav-search button:hover { + background: #e2e6ea; +} + +/* Right Collapsible Sidebar */ +.sidebar { + position: absolute; + top: 60px; /* Below navbar */ + right: 0; + bottom: 0; + width: 300px; + background-color: white; + z-index: 1000; + box-shadow: -2px 0 5px rgba(0,0,0,0.1); + transition: transform 0.3s ease-in-out; + display: flex; + flex-direction: column; +} + +.sidebar.collapsed { + transform: translateX(300px); +} + +/* Toggle Button attached to sidebar */ +.sidebar-toggle { + position: absolute; + left: -40px; + top: 10px; + width: 40px; + height: 40px; + background-color: white; + border: none; + border-radius: 5px 0 0 5px; + box-shadow: -2px 0 5px rgba(0,0,0,0.1); + cursor: pointer; + font-weight: bold; + color: #555; +} + +.sidebar-content { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.btn-new-pad { + background-color: #007bff; + color: white; + border: none; + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; + font-size: 1.2rem; + line-height: 1; +} + +/* Center Location Button */ +.btn-center-location { + width: 100%; + padding: 10px; + margin-bottom: 15px; + background-color: #17a2b8; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; +} +.btn-center-location:hover { background-color: #138496; } + +/* Add Note Button (Themed) */ +.btn-add-note { + background-color: #28a745; + color: white; + border: none; + padding: 5px 10px; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; +} +.btn-add-note:hover { background-color: #218838; } + +/* --- Auth & Content Page Styles --- */ + +/* Use this class on body for login/register/about pages */ +body.page-body { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + background: #f4f4f4; + overflow: auto; /* Allow scrolling */ +} + +.auth-container, .content-container { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + width: 100%; +} + +.auth-container { + max-width: 400px; +} + +.content-container { + max-width: 800px; + margin: 20px auto; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #333; +} + +.form-group input { + width: 100%; + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; +} + +.btn { + width: 100%; + padding: 0.75rem; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} +.btn:hover { opacity: 0.9; } +.btn-primary { background: #007bff; } +.btn-success { background: #28a745; } + +.links { margin-top: 1rem; text-align: center; font-size: 0.9rem; } + +/* Ensure buttons on content pages are white */ +.content-container .btn { color: white; } + +/* --- Modals --- */ +.modal { + position: fixed; + top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0,0,0,0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; +} + +.modal.hidden { display: none; } + +.modal-content { + background: white; + padding: 20px; + border-radius: 8px; + width: 90%; + max-width: 400px; + position: relative; +} + +.close-modal { + position: absolute; + top: 10px; right: 15px; + font-size: 1.5rem; + cursor: pointer; + color: #aaa; +} + +.btn-secondary { background: #6c757d; margin-top: 10px; } + +/* Sidebar Note Items */ +.sidebar-note-item { + padding: 10px; + border-bottom: 1px solid #eee; + cursor: pointer; +} +.sidebar-note-item:hover { + background-color: #f9f9f9; +} + +/* Map Note Card (Zoomed In) */ +.map-note-card { + background: white; + padding: 8px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + width: 200px; + font-size: 0.85rem; + border: 1px solid #ddd; + white-space: normal; +} + +/* Sidebar Sections */ +.list-section { + border-bottom: 1px solid #f0f0f0; +} + +.list-section-header, +.btn-toggle-section { + width: 100%; + text-align: left; + background: #fff; + border: none; + padding: 12px 15px; + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0; + transition: background-color 0.2s; +} +.list-section-header:hover, +.btn-toggle-section:hover { + background-color: #f8f9fa; + color: #333; +} + +.notepad-item:hover { + background-color: #fafafa; +} diff --git a/static/assets/images/spotnotes.png b/static/assets/images/spotnotes.png new file mode 100644 index 0000000..469582a Binary files /dev/null and b/static/assets/images/spotnotes.png differ diff --git a/static/assets/js/actions.js b/static/assets/js/actions.js new file mode 100644 index 0000000..2887003 --- /dev/null +++ b/static/assets/js/actions.js @@ -0,0 +1,120 @@ +document.addEventListener('DOMContentLoaded', () => { + initModals(); +}); + +let activeNotepadId = null; // Tracks which notepad is currently selected + +function initModals() { + // --- Create Notepad Logic --- + const modalPad = document.getElementById('modal-create-pad'); + const btnOpenPad = document.getElementById('btn-create-pad'); + const formPad = document.getElementById('form-create-pad'); + + if(btnOpenPad) { + btnOpenPad.addEventListener('click', () => { + modalPad.classList.remove('hidden'); + }); + } + + if(formPad) { + formPad.addEventListener('submit', async (e) => { + e.preventDefault(); + const name = document.getElementById('pad-name').value; + const isPublic = document.getElementById('pad-public').checked; + + try { + const res = await fetch('/api/notepads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, is_public: isPublic }) + }); + + if (res.ok) { + const newPad = await res.json(); + modalPad.classList.add('hidden'); + formPad.reset(); + // Transition to the new notepad + if (typeof loadNotesForPad === 'function') loadNotesForPad(newPad.id, newPad.name, newPad.is_public, newPad.owner_id); + } else { + alert('Failed to create notepad'); + } + } catch (err) { + console.error(err); + } + }); + } + + // --- Create Note Logic --- + const modalNote = document.getElementById('modal-create-note'); + const formNote = document.getElementById('form-create-note'); + + if(formNote) { + formNote.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!activeNotepadId) { + alert("No notepad selected!"); + return; + } + + const title = document.getElementById('note-title').value; + const content = document.getElementById('note-content').value; + + // Use current map center + const center = map.getCenter(); + + try { + const res = await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + content, + lat: center.lat, + lng: center.lng, + notepad_id: activeNotepadId + }) + }); + + if (res.ok) { + modalNote.classList.add('hidden'); + formNote.reset(); + // Refresh notes + if (typeof loadNotesForPad === 'function') loadNotesForPad(activeNotepadId); + } else { + alert('Failed to create note'); + } + } catch (err) { + console.error(err); + } + }); + } + + // Close buttons + document.querySelectorAll('.close-modal').forEach(btn => { + btn.addEventListener('click', (e) => { + e.target.closest('.modal').classList.add('hidden'); + }); + }); +} + +// Helper to open edit notepad modal (called from index.js) +window.openEditPadModal = function(id, currentName, currentPublic) { + document.getElementById('pad-id-edit').value = id; + document.getElementById('pad-name-edit').value = currentName; + document.getElementById('pad-public-edit').checked = currentPublic; + document.getElementById('modal-edit-pad').classList.remove('hidden'); +}; + +// Helper to open edit note modal (called from index.js) +window.openEditNoteModal = function(id, currentTitle, currentContent) { + document.getElementById('note-id-edit').value = id; + document.getElementById('note-title-edit').value = currentTitle; + document.getElementById('note-content-edit').value = currentContent; + document.getElementById('modal-edit-note').classList.remove('hidden'); +}; + +// Helper to open note modal (called from index.js) +function openCreateNoteModal(notepadId) { + activeNotepadId = notepadId; + document.getElementById('modal-create-note').classList.remove('hidden'); +} diff --git a/static/assets/js/admin.js b/static/assets/js/admin.js new file mode 100644 index 0000000..df7178e --- /dev/null +++ b/static/assets/js/admin.js @@ -0,0 +1,257 @@ +document.addEventListener('DOMContentLoaded', () => { + loadStats(); + loadUsers(); + initCollapsibles(); + initSearch(); +}); + +let selectedUserId = null; +let selectedNotepadId = null; +let allUsers = []; // Store users for client-side searching + +async function loadUsers() { + const userListDiv = document.getElementById('user-list'); + userListDiv.innerHTML = '

Loading users...

'; + + try { + const res = await fetch('/admin/users'); + if (res.status === 403) { + window.location.href = '/login.html'; + return; + } + allUsers = await res.json(); + renderUsers(allUsers); + } catch (err) { + console.error(err); + userListDiv.innerHTML = '

Error loading users.

'; + } +} + +function renderUsers(users) { + const userListDiv = document.getElementById('user-list'); + userListDiv.innerHTML = ''; + + if (users.length === 0) { + userListDiv.innerHTML = '

No users found.

'; + return; + } + + users.forEach(user => { + const userDiv = document.createElement('div'); + userDiv.className = 'user-item'; + + // Handle "Unowned" user display specifically or generic + const ipDisplay = user.id === 0 ? '' : `
IP: ${escapeHtml(user.last_ip || 'Unknown')}`; + + const infoDiv = document.createElement('div'); + infoDiv.innerHTML = `${escapeHtml(user.email)} (${escapeHtml(user.first_name)} ${escapeHtml(user.last_name)}) ${ipDisplay}`; + + const actionsDiv = document.createElement('div'); + + // Ban IP Button (only for real users with IPs) + if (user.last_ip && user.id !== 0) { + const banIpBtn = document.createElement('button'); + banIpBtn.className = 'admin-button'; + banIpBtn.textContent = 'Ban IP'; + banIpBtn.onclick = (e) => { + e.stopPropagation(); + banUserIp(user.id); + }; + actionsDiv.appendChild(banIpBtn); + } + + userDiv.appendChild(infoDiv); + userDiv.appendChild(actionsDiv); + + userDiv.addEventListener('click', () => { + selectedUserId = user.id; + loadNotepads(user.id); + expandSection('notepad-list'); + }); + userListDiv.appendChild(userDiv); + }); +} + +function initSearch() { + const searchInput = document.getElementById('user-search'); + searchInput.addEventListener('input', (e) => { + const term = e.target.value.toLowerCase(); + const filtered = allUsers.filter(user => + (user.email && user.email.toLowerCase().includes(term)) || + (user.first_name && user.first_name.toLowerCase().includes(term)) || + (user.last_name && user.last_name.toLowerCase().includes(term)) + ); + renderUsers(filtered); + }); +} + +async function loadNotepads(userId) { + const notepadListDiv = document.getElementById('notepad-list'); + notepadListDiv.innerHTML = '

Loading notepads...

'; + + try { + const res = await fetch(`/admin/users/${userId}/notepads`); + const notepads = await res.json(); + + notepadListDiv.innerHTML = ''; + notepads.forEach(notepad => { + const notepadDiv = document.createElement('div'); + notepadDiv.className = 'user-item'; + + const infoDiv = document.createElement('div'); + infoDiv.innerHTML = `${escapeHtml(notepad.name)} (Public: ${notepad.is_public})`; + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'admin-button'; + deleteBtn.textContent = 'Delete Pad'; + deleteBtn.onclick = async (e) => { + e.stopPropagation(); + if(confirm('Delete this entire notepad and all its notes?')) { + await deleteNotepad(notepad.id); + loadNotepads(userId); + } + }; + + notepadDiv.appendChild(infoDiv); + notepadDiv.appendChild(deleteBtn); + + notepadDiv.addEventListener('click', () => { + selectedNotepadId = notepad.id; + loadNotes(notepad.id); + expandSection('note-list'); + }); + notepadListDiv.appendChild(notepadDiv); + }); + } catch (err) { + console.error(err); + notepadListDiv.innerHTML = '

Error loading notepads.

'; + } +} + +async function loadNotes(notepadId) { + const noteListDiv = document.getElementById('note-list'); + noteListDiv.innerHTML = '

Loading notes...

'; + + try { + const res = await fetch(`/admin/notepads/${notepadId}/notes`); + const notes = await res.json(); + + noteListDiv.innerHTML = ''; + notes.forEach(note => { + const noteDiv = document.createElement('div'); + noteDiv.className = 'user-item'; + + const infoDiv = document.createElement('div'); + infoDiv.innerHTML = `${escapeHtml(note.title)}: ${escapeHtml(note.content)}`; + noteDiv.appendChild(infoDiv); + + // Add delete button for each note + const deleteButton = document.createElement('button'); + deleteButton.className = 'admin-button'; + deleteButton.textContent = 'Delete Note'; + deleteButton.addEventListener('click', async (e) => { + e.stopPropagation(); // Prevent selecting the note + if (confirm('Are you sure you want to delete this note?')) { + await deleteNote(note.id); + loadNotes(notepadId); // Refresh notes after deletion + } + }); + noteDiv.appendChild(deleteButton); + + noteListDiv.appendChild(noteDiv); + }); + } catch (err) { + console.error(err); + noteListDiv.innerHTML = '

Error loading notes.

'; + } +} + +async function deleteNote(noteId) { + try { + const res = await fetch(`/admin/notes/${noteId}`, { + method: 'DELETE' + }); + + if (res.ok) { + alert('Note deleted successfully.'); + } else { + alert('Failed to delete note.'); + } + } catch (err) { + console.error(err); + alert('Error deleting note.'); + } +} + +async function deleteNotepad(notepadId) { + try { + const res = await fetch(`/admin/notepads/${notepadId}`, { + method: 'DELETE' + }); + if (res.ok) { + alert('Notepad deleted.'); + } else { + alert('Failed to delete notepad.'); + } + } catch (err) { + console.error(err); + } +} + +async function banUserIp(userId) { + if(!confirm("Are you sure you want to ban this IP address? This will block all future access from this IP.")) return; + try { + const res = await fetch(`/admin/users/${userId}/ban_ip`, { method: 'POST' }); + const data = await res.json(); + alert(data.message || data.error); + } catch (err) { + console.error(err); + alert("Error banning IP"); + } +} + +async function loadStats() { + const container = document.getElementById('admin-stats'); + try { + const res = await fetch('/admin/stats'); + if (res.status === 403) { + window.location.href = '/login.html'; + return; + } + const stats = await res.json(); + + container.innerHTML = ` +

${stats.user_count}

Users

+

${stats.notepad_count}

Notepads

+

${stats.note_count}

Notes

+ `; + } catch (err) { + console.error(err); + container.innerHTML = ''; + } +} + +function initCollapsibles() { + const headers = document.querySelectorAll('.section-title'); + headers.forEach(header => { + header.addEventListener('click', () => { + const content = header.nextElementSibling; + content.classList.toggle('collapsed'); + }); + }); +} + +function expandSection(id) { + const el = document.getElementById(id); + if(el) el.classList.remove('collapsed'); +} + +function escapeHtml(text) { + if (text === null || text === undefined) return ''; + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} \ No newline at end of file diff --git a/static/assets/js/auth.js b/static/assets/js/auth.js new file mode 100644 index 0000000..87c8fb9 --- /dev/null +++ b/static/assets/js/auth.js @@ -0,0 +1,59 @@ +document.addEventListener('DOMContentLoaded', () => { + const loginForm = document.getElementById('login-form'); + const registerForm = document.getElementById('register-form'); + + if (loginForm) { + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + + try { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const data = await res.json(); + + if (res.ok) { + window.location.href = '/'; // Redirect to map + } else { + alert(data.error || 'Login failed'); + } + } catch (err) { + console.error(err); + alert('An error occurred'); + } + }); + } + + if (registerForm) { + registerForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const email = document.getElementById('email').value; + const firstName = document.getElementById('first-name').value; + const lastName = document.getElementById('last-name').value; + const password = document.getElementById('password').value; + + try { + const res = await fetch('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, first_name: firstName, last_name: lastName }) + }); + const data = await res.json(); + + if (res.ok) { + alert('Registration successful! Please log in.'); + window.location.href = '/login.html'; + } else { + alert(data.error || 'Registration failed'); + } + } catch (err) { + console.error(err); + alert('An error occurred'); + } + }); + } +}); \ No newline at end of file diff --git a/static/assets/js/index.js b/static/assets/js/index.js new file mode 100644 index 0000000..d60b096 --- /dev/null +++ b/static/assets/js/index.js @@ -0,0 +1,1024 @@ +document.addEventListener('DOMContentLoaded', () => { + initMap(); + initSidebar(); + loadNotepads(true); + + const form = document.getElementById('form-add-collaborator'); + if (form) { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const padId = document.getElementById('share-pad-id').value; + const email = document.getElementById('collaborator-email').value; + const permission = document.getElementById('collaborator-permission').value; + + try { + const res = await fetch(`/api/notepads/${padId}/collaborators`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, permission }) + }); + + if (res.ok) { + form.reset(); + const padName = document.getElementById('share-pad-name').textContent; + openShareSettingsModal(padId, padName); + } else { + const err = await res.json(); + alert(`Failed to add collaborator: ${err.error}`); + } + } catch (err) { + console.error(err); + alert('An error occurred.'); + } + }); + } + + const formEditPad = document.getElementById('form-edit-pad'); + if (formEditPad) { + formEditPad.addEventListener('submit', async (e) => { + e.preventDefault(); + const padId = document.getElementById('pad-id-edit').value; + const name = document.getElementById('pad-name-edit').value; + const isPublic = document.getElementById('pad-public-edit').checked; + + try { + const res = await fetch(`/api/notepads/${padId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, is_public: isPublic }) + }); + if (res.ok) { + document.getElementById('modal-edit-pad').classList.add('hidden'); + loadNotepads(); // Refresh everything + } else { + alert('Failed to update notepad.'); + } + } catch (err) { console.error(err); } + }); + } + + const formEditNote = document.getElementById('form-edit-note'); + if (formEditNote) { + formEditNote.addEventListener('submit', async (e) => { + e.preventDefault(); + const noteId = document.getElementById('note-id-edit').value; + const title = document.getElementById('note-title-edit').value; + const content = document.getElementById('note-content-edit').value; + + try { + const res = await fetch(`/api/notes/${noteId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, content }) + }); + if (res.ok) { + document.getElementById('modal-edit-note').classList.add('hidden'); + // Refresh current view + if (activePadId) { + loadNotesForPad(activePadId); + } else { + // This was likely from a search result, which is harder to refresh. + // A full reload of notepads is a safe fallback. + alert('Note updated. Refreshing list.'); + loadNotepads(); + } + } else { + alert('Failed to update note.'); + } + } catch (err) { console.error(err); } + }); + } +}); + +let map; +let activeNotes = []; +let userLocation = null; +let activePadName = ""; +let activePadId = null; +let activePadIsPublic = false; +let activePadOwnerId = null; +let activePadCanWrite = false; +let currentUser = null; +let isTracking = false; +let userMarker = null; +let notifiedNoteIds = new Set(); + +let isLoggedIn = false; +function initMap() { + // Default view (e.g., Oklahoma area if geolocation fails) + map = L.map('map').setView([35.6528, -97.4781], 10); + + // Ask for location + if (navigator.geolocation) { + navigator.geolocation.watchPosition((position) => { + const { latitude, longitude } = position.coords; + userLocation = { lat: latitude, lng: longitude }; + + const centerBtn = document.getElementById('btn-center-user'); + if (centerBtn) centerBtn.style.display = 'block'; + + if (isTracking) { + map.setView([latitude, longitude]); + } else if (!userMarker && !new URLSearchParams(window.location.search).has('pad') && !new URLSearchParams(window.location.search).has('note')) { + // Only auto-center if we aren't loading a shared item + // First fix + map.setView([latitude, longitude], 13); + console.log("Map centered on user location:", latitude, longitude); + } + + // Add User Location Dot + if (userMarker) { + userMarker.setLatLng([latitude, longitude]); + } else { + userMarker = L.circleMarker([latitude, longitude], { + radius: 8, + fillColor: "#3388ff", + color: "#fff", + weight: 2, + opacity: 1, + fillOpacity: 0.8 + }).addTo(map).bindPopup("You are here"); + } + + // Location-Based Browser Alerts + const userLatLng = L.latLng(latitude, longitude); + if (activeNotes && activeNotes.length > 0) { + activeNotes.forEach(note => { + const noteLatLng = L.latLng(note.lat, note.lng); + if (userLatLng.distanceTo(noteLatLng) <= 50) { + if (!notifiedNoteIds.has(note.id)) { + notifiedNoteIds.add(note.id); + alert(`You are near a note!\n\n${note.title}\n${note.content}`); + } + } + }); + } + }, (err) => { + console.warn("Geolocation error:", err); + }, { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0 + }); + } + + // Add OpenStreetMap tiles + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap' + }).addTo(map); + + // Listen for zoom changes to toggle note display + map.on('zoomend', () => { + if (activeNotes.length > 0) { + renderMapMarkers(); + } + }); + + map.on('dragstart', stopTracking); + map.on('popupopen', stopTracking); +} + +function initSidebar() { + const sidebar = document.getElementById('sidebar'); + const toggleBtn = document.getElementById('sidebar-toggle'); + + toggleBtn.addEventListener('click', () => { + sidebar.classList.toggle('collapsed'); + // Change arrow direction based on state + if (sidebar.classList.contains('collapsed')) { + const sections = document.querySelectorAll('.list-section'); + sections.forEach(section => { + section.querySelector('.list-section-content').style.display = 'none'; + }); + toggleBtn.innerHTML = '<'; // Point left (open) + } else { + toggleBtn.innerHTML = '>'; // Point right (close) + } + }); + + const centerBtn = document.getElementById('btn-center-user'); + if (centerBtn) { + centerBtn.addEventListener('click', () => { + isTracking = true; + updateTrackingUI(); + if (userLocation) { + map.setView([userLocation.lat, userLocation.lng], 16); + } + }); + } +} + +async function loadNotepads(isInitialLoad = false) { + // Reset state + activeNotes = []; + currentMarkers.forEach(marker => map.removeLayer(marker)); + currentMarkers = []; + + // Check auth first + try { + const authRes = await fetch('/auth/me'); + const authData = await authRes.json(); + isLoggedIn = authData.authenticated; + currentUser = authData.user || null; + } catch (e) { console.error(e); } + + // Reset Sidebar Header + const header = document.querySelector('.sidebar-header'); + if (header) { + let headerHTML = '

Notepads

'; + if (isLoggedIn) { + headerHTML += ''; + } + header.innerHTML = headerHTML; + + if (isLoggedIn) { + document.getElementById('btn-create-pad').addEventListener('click', () => { + document.getElementById('modal-create-pad').classList.remove('hidden'); + }); + } + } + + const sidebarBody = document.getElementById('sidebar-body'); + + if (isLoggedIn) { + sidebarBody.innerHTML = ` +
+ +
+

Loading...

+
+
+
+ +

Loading...

+
+
+ +

Loading...

+
+
+ + +
+ `; + const listSection = document.querySelectorAll('.list-section-header'); + listSection.forEach(header => { + header.addEventListener('click',toggleSection) + }) + setupPublicToggle(); + + try { + await loadNotepadsOfType('favorites', 'favorite-notepads-list'); + await loadNotepadsOfType('shared', 'shared-notepads-list'); + const res = await fetch('/api/notepads?type=mine'); + const notepads = await res.json(); + renderNotepadList(notepads, 'my-notepads-list'); + } catch (err) { + console.error("Failed to load notepads", err); + document.getElementById('my-notepads-list').innerHTML = '

Error loading data.

'; + } + } else { + // Logged out: Only Public Notepads + sidebarBody.innerHTML = ` +
+

Public Notepads

+

Loading...

+
+ `; + + try { + const res = await fetch('/api/notepads?type=public'); + const pads = await res.json(); + renderNotepadList(pads, 'public-notepads-list'); + } catch (err) { + console.error(err); + } + } + + // Initial Load Logic (Check for URL params first, else default behavior based on auth) + if (isInitialLoad === true) { + const params = new URLSearchParams(window.location.search); + if (params.has('pad')) { + const token = params.get('token'); + loadNotesForPad(params.get('pad'), undefined, undefined, undefined, true, token); + } else if (params.has('note')) { + const token = params.get('token'); + loadSingleNote(params.get('note'), token); + } else if (isLoggedIn) { + loadAllMyNotes(); // Default action for logged in users + } else { + try { + const res = await fetch('/api/notepads?type=public'); + const pads = await res.json(); + const globalPad = pads.find(p => p.name === "Global Public Notes"); + if (globalPad) { + loadNotesForPad(globalPad.id, globalPad.name, true, globalPad.owner_id); + } + } catch (err) {} + } + } +} + +function setupPublicToggle() { + const btn = document.getElementById('btn-toggle-public'); + const list = document.getElementById('public-notepads-list'); + + btn.addEventListener('click', async () => { + if (list.style.display === 'none') { + list.style.display = 'block'; + btn.innerHTML = 'Public Notepads ▾'; + + // Lazy load if empty + if (list.innerHTML === '') { + list.innerHTML = '

Loading...

'; + try { + const res = await fetch('/api/notepads?type=public'); + const pads = await res.json(); + renderNotepadList(pads, 'public-notepads-list'); + } catch (err) { + list.innerHTML = '

Error loading public pads.

'; + } + } + } else { + list.style.display = 'none'; + btn.innerHTML = 'Public Notepads ▸'; + } + }); +} + +function renderNotepadList(pads, containerId, isFavorite) { + const container = document.getElementById(containerId); + container.innerHTML = ''; + + if (pads.length === 0) { + container.innerHTML = '

No notepads found.

'; + return; + } + + pads.forEach(pad => { + const item = document.createElement('div'); + item.className = 'notepad-item'; + item.style.padding = '10px'; + item.style.borderBottom = '1px solid #eee'; + item.style.cursor = 'pointer'; + + let icon = pad.is_public ? '🌍' : '🔒'; + + let favButton = ''; + const editBtn = (currentUser && pad.owner_id === currentUser.id) ? + `` + : ''; + + const isOwner = currentUser && pad.owner_id === currentUser.id; + const manageShareBtn = isOwner ? + `` + : ''; + + if(isLoggedIn){ + const action = isFavorite ? 'unfavorite' : 'favorite'; + const label = isFavorite ? '♥' : '♡'; + favButton = ``; + } + + const deleteBtn = isOwner ? + `` + : ''; + + const shareToken = pad.share_token || ''; + const shareBtn = ``; + + item.innerHTML = ` +
+
${icon} ${escapeHtml(pad.name)}
+
+ ${editBtn} ${manageShareBtn} ${shareBtn} ${favButton} + ${deleteBtn} +
+
${pad.note_count || 0} notes
+
+ `; + + item.addEventListener('click', () => loadNotesForPad(pad.id, pad.name, pad.is_public, pad.owner_id, false, null, pad.can_write)); + container.appendChild(item); + }); +} + +let currentMarkers = []; +let topZIndex = 1000000; + +async function toggleFavorite(padId, action) { + try { + const res = await fetch(`/api/notepads/${padId}/${action}`, { method: 'POST' }); + if (res.ok) { + // Refresh the lists + loadNotepadsOfType('favorites', 'favorite-notepads-list'); + loadNotepadsOfType('mine', 'my-notepads-list'); + loadNotepadsOfType('public', 'public-notepads-list'); + + } else { + alert(`Failed to ${action} notepad`); + } + } catch (err) { + console.error(err); + } +} + +async function loadNotepadsOfType(type, containerId) { + try { + const res = await fetch(`/api/notepads?type=${type}`); + const notepads = await res.json(); + const isFavorite = type === 'favorites'; + renderNotepadList(notepads, containerId, isFavorite); + } catch (err) { + console.error(`Failed to load ${type} notepads`, err); + document.getElementById(containerId).innerHTML = '

Error loading data.

'; + } +} +async function loadNotesForPad(padId, padName, isPublic, ownerId, centerOnFirst = false, token = null, canWrite = undefined) { + activePadId = padId; + + // If metadata is missing (e.g. from URL share), fetch it + if (padName === undefined) { + try { + const res = await fetch(`/api/notepads/${padId}?token=${token || ''}`); + const pad = await res.json(); + padName = pad.name; isPublic = pad.is_public; ownerId = pad.owner_id; activePadCanWrite = pad.can_write; + } catch(e) { console.error(e); activePadCanWrite = false; } + } + + if (padName !== undefined) activePadName = padName; + if (isPublic !== undefined) activePadIsPublic = isPublic; + if (ownerId !== undefined) activePadOwnerId = ownerId; + + if (canWrite !== undefined) { + activePadCanWrite = canWrite; + } else if (padName !== undefined) { + // Fallback if not passed: assume true only if owner + activePadCanWrite = (currentUser && ownerId === currentUser.id); + } + + const currentName = activePadName; + const isOwner = currentUser && activePadOwnerId === currentUser.id; + + // Clear existing markers + currentMarkers.forEach(marker => map.removeLayer(marker)); + currentMarkers = []; + activeNotes = []; + + // Update Sidebar Header + const header = document.querySelector('.sidebar-header'); + if (header) { + const addNoteBtn = activePadCanWrite ? `` : ''; + const manageShareBtn = isOwner ? `` : ''; + const exportBtn = ``; + header.innerHTML = ` +
+ +

${escapeHtml(currentName)}

+ + ${manageShareBtn} + ${exportBtn} +
+ ${addNoteBtn} + `; + document.getElementById('btn-back-pads').addEventListener('click', loadNotepads); + } + + const sidebarBody = document.getElementById('sidebar-body'); + sidebarBody.innerHTML = '

Loading notes...

'; + + try { + const res = await fetch(`/api/notepads/${padId}/notes?token=${token || ''}`); + const notes = await res.json(); + activeNotes = notes; + + // Render Sidebar List + sidebarBody.innerHTML = ''; + if (notes.length === 0) { + sidebarBody.innerHTML = '

No notes yet.

'; + } + + notes.forEach(note => { + const item = document.createElement('div'); + item.className = 'sidebar-note-item'; + + let actionBtns = ''; + if (activePadCanWrite) { + const escapedTitle = escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '"'); + const escapedContent = escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '"').replace(/\n/g, '\\n'); + actionBtns = ` + + + `; + } else if (activePadIsPublic) { + actionBtns = ``; + } + + const dateStr = new Date(note.created_at).toLocaleString(); + + item.innerHTML = ` +
${escapeHtml(note.title)}
+
${escapeHtml(note.content)}
+
+ ${dateStr}
${note.lat.toFixed(5)}, ${note.lng.toFixed(5)} +
+
${actionBtns}
+ `; + item.addEventListener('click', () => { + stopTracking(); + map.setView([note.lat, note.lng], 16); + }); + sidebarBody.appendChild(item); + }); + + renderMapMarkers(); + + if (centerOnFirst && notes.length > 0) { + map.setView([notes[0].lat, notes[0].lng], 13); + } + + } catch (err) { + console.error(err); + sidebarBody.innerHTML = '

Error loading notes.

'; + } +} + +async function loadSingleNote(noteId, token = null) { + // Reset state + activeNotes = []; + currentMarkers.forEach(marker => map.removeLayer(marker)); + currentMarkers = []; + activePadId = null; + + const sidebarBody = document.getElementById('sidebar-body'); + sidebarBody.innerHTML = '

Loading note...

'; + + try { + const res = await fetch(`/api/notes/${noteId}?token=${token || ''}`); + if (!res.ok) throw new Error("Note not found or private"); + const note = await res.json(); + + // Set active notes to just this one so renderMapMarkers works + activeNotes = [note]; + + // Update Header + const header = document.querySelector('.sidebar-header'); + if (header) { + header.innerHTML = ` +
+ +

Shared Note

+
+ `; + document.getElementById('btn-back-pads').addEventListener('click', loadNotepads); + } + + sidebarBody.innerHTML = ''; + const noteInfoDiv = document.createElement('div'); + noteInfoDiv.style.padding = '10px'; + + const p = document.createElement('p'); + p.appendChild(document.createTextNode('Viewing single note from ')); + + if (note.notepad.is_public) { + const link = document.createElement('a'); + link.href = "#"; + link.textContent = note.notepad.name; + link.style.fontWeight = 'bold'; + link.onclick = (e) => { + e.preventDefault(); + loadNotesForPad(note.notepad.id, note.notepad.name, true, note.owner_id, false, null, note.notepad.can_write); + }; + p.appendChild(link); + } else { + const strong = document.createElement('strong'); + strong.textContent = note.notepad.name; + p.appendChild(strong); + } + p.appendChild(document.createTextNode('.')); + noteInfoDiv.appendChild(p); + sidebarBody.appendChild(noteInfoDiv); + + renderMapMarkers(); + map.setView([note.lat, note.lng], 16); + + if (window.innerWidth <= 600) { + const sidebar = document.getElementById('sidebar'); + const toggleBtn = document.getElementById('sidebar-toggle'); + if (sidebar && !sidebar.classList.contains('collapsed')) { + sidebar.classList.add('collapsed'); + if (toggleBtn) toggleBtn.innerHTML = '<'; + } + } + + } catch (err) { + sidebarBody.innerHTML = '

Failed to load note. It may be private or deleted.

'; + } +} + +function renderMapMarkers() { + // Clear existing + currentMarkers.forEach(marker => map.removeLayer(marker)); + currentMarkers = []; + + const zoom = map.getZoom(); + const showFull = zoom >= 14; // Zoom threshold + + activeNotes.forEach(note => { + let marker; + let actionBtns = ''; + + // Determine permissions: Use note-specific info if available (search results), else fall back to pad globals + const isOwner = currentUser && (note.owner_id ? note.owner_id === currentUser.id : activePadOwnerId === currentUser.id); + const isPublic = note.is_public !== undefined ? note.is_public : activePadIsPublic; + const canWrite = (note.notepad && note.notepad.can_write !== undefined) ? note.notepad.can_write : activePadCanWrite; + + if (canWrite) { + const escapedTitle = escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '"'); + const escapedContent = escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '"').replace(/\n/g, '\\n'); + actionBtns = `
`; + } else if (isPublic) { + actionBtns = `
`; + } + + const shareToken = note.share_token || ''; + const shareBtn = ``; + + const dateStr = new Date(note.created_at).toLocaleString(); + + + + if (showFull) { + const icon = L.divIcon({ + className: '', + html: `
+
${escapeHtml(note.title)}
+
${escapeHtml(note.content)}
+
${dateStr}
+ ${shareBtn} ${actionBtns} +
`, + iconSize: [200, 'auto'], + iconAnchor: [100, 0] + }); + marker = L.marker([note.lat, note.lng], { icon: icon }).addTo(map); + } else { + marker = L.marker([note.lat, note.lng]).addTo(map); + marker.bindPopup(`${escapeHtml(note.title)}
${escapeHtml(note.content)}
${dateStr}
${shareBtn} ${actionBtns}`); + } + const bumpZIndex = function() { + this.setZIndexOffset(topZIndex++); + }; + marker.on('click', bumpZIndex); + marker.on('mousedown', bumpZIndex); + + currentMarkers.push(marker); + }); +} + +window.displaySearchResults = function(notes, location, query) { + // Reset Pad State to "Search Mode" + activePadId = null; + activePadName = `Search: ${query}`; + activePadIsPublic = false; // Mixed results + activePadOwnerId = null; // Mixed results + activeNotes = notes; + + // Update Sidebar Header + const header = document.querySelector('.sidebar-header'); + if (header) { + header.innerHTML = ` +
+ +

Results: ${escapeHtml(query)}

+
+ `; + document.getElementById('btn-back-pads').addEventListener('click', loadNotepads); + } + + const sidebarBody = document.getElementById('sidebar-body'); + sidebarBody.innerHTML = ''; + + if (notes.length === 0 && !location) { + sidebarBody.innerHTML = '

No results found.

'; + renderMapMarkers(); + return; + } else if (notes.length === 0 && location) { + sidebarBody.innerHTML = '

Location found on map.

'; + renderMapMarkers(); + map.setView([location.lat, location.lng], 13); + return; + } + + // Group notes by section and notepad + const sections = { + favorites: {}, // Map + mine: {}, + public: {} + }; + + notes.forEach(note => { + const pad = note.notepad; + + // Helper to add to a section + const addToSection = (secKey) => { + if (!sections[secKey][pad.id]) { + sections[secKey][pad.id] = { pad: pad, notes: [] }; + } + sections[secKey][pad.id].notes.push(note); + }; + + if (pad.is_favorite) addToSection('favorites'); + if (currentUser && pad.owner_id === currentUser.id) addToSection('mine'); + if (pad.is_public) addToSection('public'); + }); + + // Helper function to render a collapsible section + const renderSection = (title, dataMap, isOpen) => { + const sectionDiv = document.createElement('div'); + sectionDiv.className = 'list-section'; + + const arrow = isOpen ? '▾' : '▸'; + const display = isOpen ? 'block' : 'none'; + + const headerBtn = document.createElement('button'); + headerBtn.className = 'list-section-header'; + headerBtn.innerHTML = `${title} ${arrow}`; + headerBtn.onclick = toggleSection; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'list-section-content'; + contentDiv.style.display = display; + + const padIds = Object.keys(dataMap); + if (padIds.length === 0) { + contentDiv.innerHTML = '

No matching notes.

'; + } else { + padIds.forEach(padId => { + const entry = dataMap[padId]; + const pad = entry.pad; + + // Notepad Header, styled like the default view + const padItem = document.createElement('div'); + padItem.className = 'notepad-item'; + padItem.style.backgroundColor = '#e9ecef'; // Differentiate as a sub-header + padItem.style.cursor = 'default'; + + let icon = pad.is_public ? '🌍' : '🔒'; + let favButton = ''; + if(isLoggedIn){ + const isFavorite = pad.is_favorite; + const action = isFavorite ? 'unfavorite' : 'favorite'; + const label = isFavorite ? '♥' : '♡'; + favButton = ``; + } + + padItem.innerHTML = ` +
+
${icon} ${escapeHtml(pad.name)}
+
+ ${favButton} +
+
+ `; + contentDiv.appendChild(padItem); + + entry.notes.forEach(note => { + const item = document.createElement('div'); + item.className = 'sidebar-note-item'; + const dateStr = new Date(note.created_at).toLocaleString(); + + let actionBtn = ''; // Renaming to actionBtns for consistency + if (note.notepad && note.notepad.can_write) { + const escapedTitle = escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '"'); + const escapedContent = escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '"').replace(/\n/g, '\\n'); + actionBtn = ` `; + } else if (note.is_public) { + actionBtn = ``; + } + + item.innerHTML = ` +
${escapeHtml(note.title)}
+
${escapeHtml(note.content)}
+
+ ${dateStr}
${note.lat.toFixed(5)}, ${note.lng.toFixed(5)} +
+
${actionBtn}
+ `; + item.addEventListener('click', () => { + stopTracking(); + map.setView([note.lat, note.lng], 16); + }); + contentDiv.appendChild(item); + }); + }); + } + + sectionDiv.appendChild(headerBtn); + sectionDiv.appendChild(contentDiv); + sidebarBody.appendChild(sectionDiv); + }; + + if (isLoggedIn) { + if (Object.keys(sections.favorites).length > 0) renderSection('Favorite Notepads', sections.favorites, true); + if (Object.keys(sections.mine).length > 0) renderSection('My Notepads', sections.mine, true); + if (Object.keys(sections.public).length > 0) renderSection('Public Notepads', sections.public, true); + } else { + if (Object.keys(sections.public).length > 0) renderSection('Public Notepads', sections.public, true); + } + + renderMapMarkers(); + + if (notes.length > 0) { + map.setView([notes[0].lat, notes[0].lng], 15); + } else if (location) { + map.setView([location.lat, location.lng], 13); + } +}; + +window.reportNote = async function(noteId) { + if (!confirm("Report this note? It will be hidden from public view.")) return; + + try { + const res = await fetch(`/api/notes/${noteId}/report`, { method: 'POST' }); + if (res.ok) { + alert("Note reported and hidden."); + // Refresh current pad + loadNotesForPad(activePadId, activePadName, activePadIsPublic); + } + } catch (err) { + console.error("Error reporting note:", err); + } +}; + +window.deleteNotepad = async function(padId) { + if (!confirm("Are you sure you want to delete this notepad? All notes inside will be lost.")) return; + + try { + const res = await fetch(`/api/notepads/${padId}`, { method: 'DELETE' }); + if (res.ok) { + loadNotepads(); // Refresh list + } else { + alert("Failed to delete notepad."); + } + } catch (err) { console.error(err); } +}; + +window.openShareSettingsModal = async function(padId, padName) { + const modal = document.getElementById('modal-share-settings'); + document.getElementById('share-pad-name').textContent = padName; + document.getElementById('share-pad-id').value = padId; + + const listDiv = document.getElementById('collaborators-list'); + listDiv.innerHTML = '

Loading...

'; + modal.classList.remove('hidden'); + + try { + const res = await fetch(`/api/notepads/${padId}/collaborators`); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to load collaborators'); + } + const collaborators = await res.json(); + + listDiv.innerHTML = ''; + if (collaborators.length === 0) { + listDiv.innerHTML = '

No collaborators yet.

'; + } else { + collaborators.forEach(collab => { + const item = document.createElement('div'); + item.className = 'collaborator-item'; + item.innerHTML = ` + ${escapeHtml(collab.email)} (${collab.permission}) + + `; + listDiv.appendChild(item); + }); + } + } catch (err) { + listDiv.innerHTML = `

${err.message}

`; + } +} + +window.removeCollaborator = async function(padId, userId) { + if (!confirm("Are you sure you want to remove this collaborator?")) return; + + try { + const res = await fetch(`/api/notepads/${padId}/collaborators/${userId}`, { method: 'DELETE' }); + if (res.ok) { + const padName = document.getElementById('share-pad-name').textContent; + openShareSettingsModal(padId, padName); + } else { + const err = await res.json(); + alert(`Failed to remove collaborator: ${err.error}`); + } + } catch (err) { + console.error(err); + alert('An error occurred.'); + } +} + +async function loadAllMyNotes() { + activePadId = null; + activePadName = "All My Notes"; + activePadIsPublic = false; + activePadCanWrite = true; + if (currentUser) { + activePadOwnerId = currentUser.id; + } + + // Clear existing markers + currentMarkers.forEach(marker => map.removeLayer(marker)); + currentMarkers = []; + activeNotes = []; + + try { + const res = await fetch('/api/notes/mine'); + if (!res.ok) throw new Error("Failed to load"); + const notes = await res.json(); + activeNotes = notes; + + renderMapMarkers(); + + if (notes.length > 0) { + // Auto-center map bounds cleanly around all the user's notes + const bounds = L.latLngBounds(notes.map(n => [n.lat, n.lng])); + map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 }); + } + + } catch (err) { + console.error("Error loading all my notes:", err); + } +} + +window.shareNotepad = function(padId, token) { + let url = `${window.location.origin}/?pad=${padId}`; + if (token) url += `&token=${token}`; + navigator.clipboard.writeText(url).then(() => { + alert("Notepad link copied to clipboard!"); + }); +}; + +window.shareNote = function(noteId, token) { + let url = `${window.location.origin}/?note=${noteId}`; + if (token) url += `&token=${token}`; + navigator.clipboard.writeText(url).then(() => { + alert("Note link copied to clipboard!"); + }); +}; + +function stopTracking() { + isTracking = false; + updateTrackingUI(); +} + +function updateTrackingUI() { + const btn = document.getElementById('btn-center-user'); + if (btn) { + if (isTracking) { + btn.textContent = "Tracking Location..."; + btn.style.backgroundColor = "#28a745"; + } else { + btn.textContent = "Target My Location"; + btn.style.backgroundColor = ""; + } + } +} + +function escapeHtml(text) { + if (text === null || text === undefined) return ''; + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function toggleSection(event) { + const section = event.target.closest('.list-section'); + const content = section.querySelector('.list-section-content'); + const isCollapsed = content.style.display === 'none'; + content.style.display = isCollapsed ? 'block' : 'none'; + event.target.innerHTML = isCollapsed ? event.target.innerHTML.replace('▸', '▾') : event.target.innerHTML.replace('▾', '▸'); +} + +window.deleteNote = async function(noteId) { + if (!confirm("Delete this note?")) return; + + try { + const res = await fetch(`/api/notes/${noteId}`, { method: 'DELETE' }); + if (res.ok) { + // If we are in a pad view, reload the pad. + if (activePadId) { + loadNotesForPad(activePadId); + } else { + // Otherwise (e.g., search results), the safest refresh is to return to the main list. + alert("Note deleted. Returning to notepad list."); + loadNotepads(); + } + } else { + alert("Failed to delete note."); + } + } catch (err) { console.error(err); } +}; \ No newline at end of file diff --git a/static/assets/js/nav.js b/static/assets/js/nav.js new file mode 100644 index 0000000..8484dce --- /dev/null +++ b/static/assets/js/nav.js @@ -0,0 +1,42 @@ +document.addEventListener('DOMContentLoaded', () => { + checkAuthStatus(); + initLogoutListener(); +}); + +async function checkAuthStatus() { + try { + const res = await fetch('/auth/me'); + const data = await res.json(); + updateAuthUI(data.authenticated); + } catch (err) { + console.error("Auth check failed", err); + } +} + +function initLogoutListener() { + const logoutLink = document.getElementById('link-logout'); + if (logoutLink) { + logoutLink.addEventListener('click', async (e) => { + e.preventDefault(); + try { + await fetch('/auth/logout', { method: 'POST' }); + window.location.reload(); + } catch (err) { + console.error("Logout failed", err); + } + }); + } +} + +function updateAuthUI(isLoggedIn) { + const loginLink = document.getElementById('link-login'); + const logoutLink = document.getElementById('link-logout'); + + if (isLoggedIn) { + if (loginLink) loginLink.style.display = 'none'; + if (logoutLink) logoutLink.style.display = 'inline'; + } else { + if (loginLink) loginLink.style.display = 'inline'; + if (logoutLink) logoutLink.style.display = 'none'; + } +} \ No newline at end of file diff --git a/static/assets/js/search.js b/static/assets/js/search.js new file mode 100644 index 0000000..caadbf0 --- /dev/null +++ b/static/assets/js/search.js @@ -0,0 +1,59 @@ +document.addEventListener('DOMContentLoaded', () => { + const searchInput = document.getElementById('search-input'); + const searchForm = document.getElementById('search-form'); + + if (searchInput && searchForm) { + const performSearch = async (e) => { + if (e) e.preventDefault(); + const query = searchInput.value.trim(); + if (!query) return; + + // Show loading state if possible, or just proceed + + // 1. Search Notes (Backend) + let notes = []; + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + if (res.ok) { + notes = await res.json(); + } else { + console.error("Search API failed:", res.status); + } + } catch (e) { + console.error("Search error:", e); + } + + // If notes found, display them and stop (don't geocode) + if (notes.length > 0) { + if (window.displaySearchResults) { + window.displaySearchResults(notes, null, query); + } + return; + } + + // 2. Fallback: Search Location (Nominatim) if no notes found + let location = null; + try { + // Use local proxy to avoid CORS and set User-Agent + const nomRes = await fetch(`/api/geocode?q=${encodeURIComponent(query)}`); + const nomData = await nomRes.json(); + if (nomData && nomData.length > 0) { + location = { + lat: parseFloat(nomData[0].lat), + lng: parseFloat(nomData[0].lon) + }; + } + } catch (e) { + console.error("Geocoding error:", e); + } + + // 3. Update UI via index.js helper + if (window.displaySearchResults) { + window.displaySearchResults(notes, location, query); + } + }; + + // Handle Enter key on input & Search Button click + searchForm.addEventListener('submit', performSearch); + } +}); diff --git a/static/contact.html b/static/contact.html new file mode 100644 index 0000000..6d58802 --- /dev/null +++ b/static/contact.html @@ -0,0 +1,38 @@ + + + + + + Contact - SpotNotes + + + + + + + + +
+ SpotNotes.app + +

Contact

+

+ benjimanmiller@gmail.com +

+ +
+ + + \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..a69f4e4 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..f9dd9cd --- /dev/null +++ b/static/index.html @@ -0,0 +1,185 @@ + + + + + + SpotNotes.app - Location Based Notes + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..bf5a0cc --- /dev/null +++ b/static/login.html @@ -0,0 +1,47 @@ + + + + + + Login - SpotNotes.app + + + + + + + + +
+ SpotNotes.app + +

Login

+
+
+ + +
+
+ + +
+ +
+ +
+ + + \ No newline at end of file diff --git a/static/register.html b/static/register.html new file mode 100644 index 0000000..69e30d5 --- /dev/null +++ b/static/register.html @@ -0,0 +1,55 @@ + + + + + + Register - SpotNotes.app + + + + + + + + +
+ SpotNotes.app + +

Create Account

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + + \ No newline at end of file