intial commit

This commit is contained in:
Ben Miller 2026-05-12 18:58:18 -05:00
commit 6ec01760eb
50 changed files with 4586 additions and 0 deletions

213
.gitignore vendored Normal file
View file

@ -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__/

40
GEMINI.md Normal file
View file

@ -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 `<input type="color">` 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".

1
Procfile Normal file
View file

@ -0,0 +1 @@
web: gunicorn --worker-tmp-dir /dev/shm --config gunicorn_config.py "app:create_app()"

101
README.md Normal file
View file

@ -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 <repository-url>
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.

34
app.py Normal file
View file

@ -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)

13
blueprints/__init__.py Normal file
View file

@ -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)

126
blueprints/admin.py Normal file
View file

@ -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/<int:user_id>/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/<int:notepad_id>/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/<int:note_id>', 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/<int:notepad_id>', 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/<int:user_id>', 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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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

391
blueprints/api.py Normal file
View file

@ -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/<int:pad_id>', 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/<int:pad_id>/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/<int:note_id>', 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/<int:note_id>', 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/<int:pad_id>', 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/<int:note_id>/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/<int:pad_id>/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/<int:pad_id>/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/<int:pad_id>/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/<int:pad_id>/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/<int:pad_id>/collaborators/<int:collaborator_user_id>', 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/<int:pad_id>/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/<int:pad_id>', 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/<int:note_id>', 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

72
blueprints/auth.py Normal file
View file

@ -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

79
blueprints/search.py Normal file
View file

@ -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

View file

@ -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('/<path:path>')
def serve_static(path):
return current_app.send_static_file(path)

9
config.py Normal file
View file

@ -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

5
extensions.py Normal file
View file

@ -0,0 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
db = SQLAlchemy()
cors = CORS()

2
gunicorn_config.py Normal file
View file

@ -0,0 +1,2 @@
bind = "0.0.0.0:8081"
workers = 2

7
models/banned_ip.py Normal file
View file

@ -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)

25
models/collaborator.py Normal file
View file

@ -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
}

10
models/favorite.py Normal file
View file

@ -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'),
)

22
models/note.py Normal file
View file

@ -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()
}

21
models/notepad.py Normal file
View file

@ -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()
}

29
models/user.py Normal file
View file

@ -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
}

51
notes/birding.csv Normal file
View file

@ -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
Can't render this file because it contains an unexpected character in line 2 and column 17.

View file

@ -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
1 title content lat lng
2 Area 51 Highly classified Air Force facility long associated with UFO and alien cover-ups. 37.2343 -115.8108
3 Denver International Airport Subject of theories involving underground bunkers, New World Order murals, and gargoyles. 39.8561 -104.6737
4 Dulce Base Alleged secret underground laboratory where humans and aliens collaborate on experiments. 36.9395 -106.9986
5 The Greenbrier Bunker Once-secret massive underground facility designed to house Congress in the event of nuclear war. 37.7854 -80.3083
6 The Georgia Guidestones Site The site where granite monuments with 'commandments' for the New World Order stood until 2022. 34.2324 -82.8946
7 Plum Island Animal Disease Center Often linked to theories about the origin of Lyme disease and secret bio-warfare testing. 41.1736 -72.1864
8 The HAARP Facility A research site often accused of being a secret weather-control or mind-control weapon. 62.3933 -145.15
9 Mount Weather Emergency Operations Center The real-world 'safe house' for high-ranking officials during national disasters. 39.0631 -77.8892
10 Montauk Project Site (Camp Hero) Alleged site of secret government projects involving time travel and psychological warfare. 41.0628 -71.8744
11 The Bohemian Grove A private campground where world leaders gather for secretive rituals in the redwoods. 38.48 -123.0019
12 S-4 Facility Alleged secret site near Papoose Lake rumored to house recovered extraterrestrial craft. 37.0863 -115.8208
13 Winchester Mystery House Built continuously to confuse the spirits of those killed by Winchester rifles. 37.3184 -121.9511
14 Dealey Plaza Site of the JFK assassination and the source of endless 'grassy knoll' theories. 32.779 -96.8082
15 The Octagon House Rumored to have secret tunnels and connections to early government occultism. 38.8995 -77.0403
16 The Pentagon Central hub of US defense, often center-stage in massive structural and strategic theories. 38.8719 -77.0563
17 Point Pleasant (Mothman Site) Location of numerous sightings of the Mothman before the Silver Bridge collapse. 38.8445 -82.1371
18 Roswell UFO Crash Site The epicenter of modern UFO conspiracy theories involving a 1947 crash. 33.3944 -104.523
19 Coral Castle Built by one man who supposedly used magnetism and anti-gravity to move massive stones. 25.5003 -80.4442
20 Skinwalker Ranch A property famous for alleged paranormal activity, UFOs, and shape-shifters. 40.2526 -109.8872
21 Brown Mountain Lights Overlook Site of mysterious ghost lights that have defied scientific explanation for a century. 35.845 -81.745
22 Marfa Lights Viewing Area Atmospheric lights that have spawned theories ranging from ghosts to alien scouts. 30.2764 -103.8831
23 The Integration A structure built in the desert intended to act as a rejuvenation machine and time travel portal. 34.2986 -116.4036
24 Teotihuacan Ancient city often cited by ancient astronaut theorists for its planetary alignment. 19.6925 -98.8437
25 San Luis Valley A 'paranormal hotspot' known for cattle mutilations and high UFO traffic. 37.7492 -105.85
26 Mount Shasta A volcanic peak rumored to hide the underground city of Telos, home to Lemurians. 41.4092 -122.1949
27 The Stanley Hotel Famous haunted hotel that inspired 'The Shining' and is center to psychic theories. 40.3833 -105.5189
28 Bridgewater Triangle A region in Massachusetts known for cryptids, cult activity, and UFOs. 41.936 -71.026
29 Bennington Triangle Area of mysterious disappearances that some link to supernatural vortexes. 42.8781 -73.1968
30 The Superstition Mountains Home of the Lost Dutchman's Gold Mine and legends of underground gateways. 33.45 -111.35
31 Bigfoot Discovery Museum The headquarters for researchers tracking the legendary forest cryptid. 37.0505 -122.0725
32 Sedona Vortexes Sites believed to emit swirling centers of energy that affect the human spirit. 34.8697 -111.761
33 International Cryptozoology Museum Preserves evidence and artifacts from sightings of unproven animals. 43.6565 -70.2558
34 Project Sign/Grudge Site (Wright-Patterson) Base rumored to be the true location where Roswell debris was stored. 39.8136 -84.0494
35 Cheyenne Mountain Complex Underground bunker that serves as the command center for NORAD. 38.7442 -104.8461
36 Raven Rock Mountain Complex The 'underground Pentagon' designed to sustain government after an attack. 39.7333 -77.4167
37 Site R Alternative name for the secret Raven Rock facility in Pennsylvania. 39.734 -77.417
38 The Archuleta Mesa High-altitude site above Dulce rumored to be an alien landing strip. 36.994 -106.942
39 Blackwood Chasm Alleged site of unexplained seismic activity and 'hollow earth' rumors. 35.1234 -112.5678
40 The Great Dismal Swamp Site of many 'maroon' colony legends and strange atmospheric phenomena. 36.6384 -76.4789
41 Oak Island Money Pit Site of a 200-year-old treasure hunt involving elaborate traps and theories. 44.5133 -64.2953
42 Bermuda Triangle Tip (Miami) Eastern edge of the zone where ships and planes famously vanish. 25.7617 -80.1918
43 Point Dume Rumored underwater base entrance for UFOs coming from the Pacific. 34.0012 -118.8068
44 Yakima UFO Hotspot A location on the Yakima Indian Reservation with high frequency of 'orb' sightings. 46.2307 -120.6552
45 Groom Lake The dry lake bed adjacent to Area 51 used for secret test flights. 37.2758 -115.7989
46 Tonopah Test Range Site used for testing stealth technology and captured foreign aircraft. 37.795 -116.7722
47 Dugway Proving Ground Often called 'Area 52', known for testing chemical and biological weapons. 40.2 -113.15
48 The Winchester Vault A specific locked room where Sarah Winchester allegedly held séances. 37.3185 -121.951
49 The Dakota Building Historical NYC residence associated with cult theories and the occult. 40.776 -73.9763
50 Granite Mountain Records Vault Massive Mormon genealogy vault built into a mountain for protection. 40.5728 -111.7711
51 The Sphinx (Giza) Ancient monument often the subject of 'Hall of Records' underground theories. 29.9753 31.1376

101
notes/general_public.csv Normal file
View file

@ -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
1 title content lat lng
2 The Center of the Universe An acoustic anomaly in Tulsa where you can shout and only you hear the echo. 36.1553 -95.9931
3 The World's Tallest Thermometer A 134-foot tall reminder that Baker, California, is basically the surface of the sun. 35.2661 -116.0725
4 Carhenge A faithful replica of Stonehenge made entirely of vintage American automobiles. 42.1422 -102.8581
5 The Fremont Troll A massive stone troll living under a bridge that is currently eating a real VW Beetle. 47.651 -122.3473
6 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
7 Unclaimed Baggage Center The only place on Earth where you can buy someone else's lost vacation dreams. 34.8697 -86.0028
8 The Maine Ridge Runner A tribute to the man who lived in the woods and stole only library books. 44.5497 -69.7797
9 Cadillac Ranch Graffiti A public note to the world where everyone is encouraged to bring spray paint. 35.1872 -101.987
10 The Muffler Man A giant fiberglass Paul Bunyan who lost his axe and now just looks confused. 35.5122 -98.9819
11 World's Largest Ball of Twine Built by one man who just didn't know when to stop winding. 39.4842 -97.6475
12 The Mushroom Houses Architectural notes in stone that look like they were designed by a Smurf. 45.3167 -85.2583
13 Berkeley's Fish House A house shaped like a giant fish because why live in a square box? 40.015 -105.2705
14 The Prada Marfa A permanent "store" in the middle of the desert that will never actually open. 30.6035 -104.5185
15 The Beer Can House A retirement project involving 50,000 flattened cans and a lot of patience. 29.7711 -95.4294
16 The Republic of Molossia A sovereign "micronation" in Nevada that is currently at war with East Germany. 39.3233 -119.5397
17 The Oregon Vortex A "forbidden" patch of land where brooms stand up and people look shorter. 42.4936 -123.0844
18 Dinosaur Park A collection of green concrete monsters that haven't aged a day since 1936. 44.0761 -103.2436
19 The Paper House A home built out of 100,000 newspapers that has survived since 1922. 42.6681 -70.6289
20 The Jolly Green Giant A 55-foot tall statue that makes every visitor feel like a side dish of peas. 43.6483 -94.1039
21 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
22 Lucy the Elephant A six-story high wooden elephant that used to be a real estate office. 39.3225 -74.4533
23 Desert Christ Park A hillside covered in giant statues waiting for a movie that never started. 34.1197 -116.4447
24 The Gum Wall A colorful, sticky, and slightly gross public mosaic of chewed-up history. 47.6083 -122.34
25 Ave Maria Grotto A tiny world of 125 miniature stone shrines built by a hunchbacked monk. 34.1758 -86.8294
26 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
27 The Spite House A very narrow home built specifically to block a neighbor's view of the street. 42.3781 -71.1167
28 Coral Castle A monument built by a 100-pound man using "the secrets of the pyramids." 25.5006 -80.4442
29 The Haines Shoe House A literal giant shoe that you can actually sleep in. 39.9928 -76.6347
30 The Toilet Seat Art Museum A collection of over 1,000 lids proving that anything can be a canvas. 35.2014 -101.8317
31 Enchanted Highway A stretch of road featuring a 110-foot tall family made of scrap metal. 46.4719 -102.5589
32 The Peachoid A water tower that looks suspiciously like a giant bottom from the highway. 35.0808 -81.6739
33 The Big Duck A poultry-shaped building designed specifically to sell duck eggs. 40.8233 -72.7153
34 The Hat Museum A tribute to the days when nobody left the house without a topper. 45.5122 -122.6758
35 Jimmy Carter's Peanut A giant smiling peanut statue that bears an uncanny resemblance to the president. 32.0436 -84.3917
36 The UFO Watchtower A platform in the desert where people wait for a ride to the stars. 37.935 -105.8753
37 The Longaberger Basket A seven-story office building that looks exactly like a picnic basket. 40.0636 -82.3458
38 Vollis Simpson Whirligig Park A field of massive, colorful kinetic sculptures made from farm parts. 35.7311 -77.9125
39 The Bone Museum An Oklahoma City gem where the skeletons literally tell the story. 35.3942 -97.4933
40 The Wigwam Village No. 2 A 1930s hotel where you can tell people you "slept in a teepee." 37.1472 -85.9189
41 The Blue Hole A natural artesian spring in the desert that stays 62 degrees all year. 34.9439 -104.6736
42 Salem Witch Trials Memorial A somber note in stone to those who were "not actually witches." 42.5211 -70.8911
43 The Evolution of the O A statue at OU that students touch for luck before finals. 35.2089 -97.4456
44 Old North Tower A historic building on UCO's campus that is a landmark for every Edmond local. 35.6558 -97.4725
45 World's Smallest Police Station A repurposed phone booth that was used by the local force until 1990. 29.8136 -84.6589
46 The Egg City A tribute to the once-thriving poultry industry that now looks like an alien base. 34.2814 -118.8833
47 Sleepy Hollow Headless Horseman A bridge where people still look over their shoulders for a pumpkin-headed rider. 41.0903 -73.8617
48 The International UFO Museum The only place where "I saw a weather balloon" is an insult. 33.3944 -104.5231
49 Marvin's Marvelous Mechanical Museum A chaotic collection of vintage coin-op machines and weird neon signs. 42.5286 -83.3528
50 The Winchester House Mystery A home with stairs that lead nowhere and doors that open to drops. 37.3184 -121.9367
51 The Neon Museum The "Boneyard" where retired Las Vegas signs go to spend their golden years. 36.1769 -115.1356
52 Lucy the Elephant A six-story elephant built in 1881 to sell real estate and survived several hurricanes. 39.3233 -74.4533
53 Prada Marfa A permanently closed luxury boutique in the middle of the Texas desert that is actually an art installation. 30.6035 -104.5185
54 The Corn Palace A multipurpose arena decorated annually with murals made entirely of corn and other grains. 43.7142 -98.0253
55 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
56 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
57 Salvation Mountain A massive colorful art installation made of adobe and straw, covered in thousands of gallons of paint. 33.2538 -115.4731
58 Seven Magic Mountains A desert art installation featuring seven towers of colorful, stacked boulders. 35.8383 -115.2708
59 Musical Highway A stretch of Route 66 that plays 'America the Beautiful' if you drive exactly 45 mph. 35.0744 -106.4322
60 Carving of Crazy Horse A mountain monument under construction since 1948 that will eventually be the world's largest sculpture. 43.8367 -103.6244
61 Bottle Tree Ranch A forest of 'trees' made from glass bottles and scrap metal along old Route 66. 34.6908 -117.3374
62 Wigwam Motel A classic motel where you sleep in individual concrete teepees. 34.9022 -110.1581
63 London Bridge The actual bridge from London, dismantled and moved to the Arizona desert in 1968. 34.4717 -114.3472
64 Meteor Crater The best-preserved meteorite impact site on Earth, nearly one mile across. 35.0272 -111.0225
65 Bishop Castle A massive stone castle built by one man over 60 years using local rocks. 38.0614 -105.0942
66 The Stanley Hotel The inspiration for Stephen King's 'The Shining' and a hotspot for ghost hunters. 40.3833 -105.5189
67 Meow Wolf Santa Fe An immersive art experience that starts in a Victorian house and leads to psychedelic dimensions. 35.6548 -105.9967
68 Cadillac Ranch Ten Cadillacs buried nose-first in the ground, inviting visitors to leave their mark with spray paint. 35.1872 -101.987
69 Hole N' The Rock A 5,000 square foot home carved directly into a massive sandstone rock. 38.3912 -109.4567
70 Enchanted Highway A collection of the world's largest scrap metal sculptures along a 32-mile stretch of road. 46.4674 -102.5583
71 Cabazon Dinosaurs Giant roadside dinosaurs featured in the movie 'Pee-wee's Big Adventure'. 33.92 -116.7725
72 Winslow Corner A park dedicated to the Eagles' song 'Take It Easy', complete with a flatbed Ford. 35.0242 -110.6975
73 Hoover Dam A massive concrete arch-gravity dam in the Black Canyon of the Colorado River. 36.0162 -114.7372
74 Four Corners Monument The only place in the US where you can stand in four states at the same time. 36.9991 -109.0452
75 Shiprock A towering volcanic rock formation that rises 1,500 feet above the desert floor. 36.6872 -108.8364
76 Very Large Array A massive radio astronomy observatory consisting of 27 giant dish antennas. 34.079 -107.6184
77 Rio Grande Gorge Bridge The tenth highest bridge in the United States, offering dizzying views of the river below. 36.4764 -105.733
78 Garden of the Gods A park of towering red sandstone rock formations against a backdrop of Pikes Peak. 38.8785 -104.8806
79 Royal Gorge Bridge One of the world's highest suspension bridges, hanging 955 feet above the Arkansas River. 38.4614 -105.3253
80 Casa Bonita A legendary pink Mexican restaurant with indoor waterfalls, cliff divers, and puppet shows. 39.7419 -105.0708
81 Coral Castle An oolite limestone structure built by one man who claimed to know the secrets of the pyramids. 25.5003 -80.4442
82 Ames Pyramid A 60-foot tall granite pyramid built to honor the brothers who financed the Union Pacific Railroad. 41.1314 -105.3975
83 Market Theater Gum Wall A brick wall covered entirely in used chewing gum, considered a local landmark. 47.6084 -122.3404
84 The Big Basket A seven-story office building shaped exactly like a Longaberger picnic basket. 40.0633 -82.3464
85 Carhenge A replica of England's Stonehenge formed from vintage American automobiles. 42.1422 -102.8581
86 Winchester Mystery House A massive Victorian mansion built continuously for 38 years to appease spirits. 37.3184 -121.9511
87 Dinosaur Park A hilltop park featuring seven giant concrete dinosaur sculptures built during the Great Depression. 44.0789 -103.2458
88 International UFO Museum A museum dedicated to the 1947 Roswell UFO incident and all things extraterrestrial. 33.3944 -104.523
89 Gatorland The 'Alligator Capital of the World', featuring thousands of gators and crocodiles. 28.3556 -81.3364
90 The Blue Hole A sapphire-blue artesian spring in the middle of the New Mexico desert. 34.9439 -104.6736
91 Dinosaur Kingdom II A bizarre theme park where Union soldiers fight dinosaurs in the Civil War. 37.6631 -79.4883
92 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
93 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
94 Ava Maria Grotto A park containing 125 miniature replicas of famous religious structures from around the world. 34.1764 -86.8331
95 The Unclaimed Baggage Center The only store in the world that buys and sells lost luggage from airlines. 34.8697 -86.0028
96 Voodoo Doughnut A quirky doughnut shop known for its eccentric decor and unconventional toppings. 45.5227 -122.6731
97 The Heidelberg Project An open-air art environment in Detroit that transformed a neighborhood into a massive gallery. 42.3584 -83.0214
98 Wall Drug The legendary drug store that grew from free ice water into a world-famous attraction. 43.9928 -102.2414
99 The Salt Palace A salt-covered building in Utah where you can literally lick the walls. 40.7667 -111.895
100 Paper House A house built entirely out of newspapers by a mechanical engineer in 1922. 42.6622 -70.6289
101 Dog Bark Park Inn A bed and breakfast shaped like a giant beagle, where guests sleep inside the dog. 46.0583 -116.4447

View file

@ -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
Can't render this file because it contains an unexpected character in line 18 and column 18.

51
notes/haunted-usa.csv Normal file
View file

@ -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
Can't render this file because it contains an unexpected character in line 2 and column 32.

51
notes/route66.csv Normal file
View file

@ -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
Can't render this file because it contains an unexpected character in line 3 and column 29.

6
requirements.txt Normal file
View file

@ -0,0 +1,6 @@
Flask
requests
Flask-Cors
gunicorn
Flask-SQLAlchemy
python-dotenv

80
seed_notes.py Normal file
View file

@ -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 <csv_file> <notepad_name> [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)

88
static/about.html Normal file
View file

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>About - SpotNotes.app</title>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/navbar.css">
<link rel="stylesheet" href="/assets/css/auth.css">
<link rel="icon" href="favicon.ico" type="image/x-icon" />
</head>
<body class="page-body">
<nav class="navbar">
<a href="/" class="brand">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="height: 44px;">
</a>
<div class="nav-links">
<a href="/login.html" id="link-login">Login</a>
<a href="/about.html">About</a>
<a href="/contact.html">Contact</a>
<a href="#" id="link-logout" style="display: none;">Logout</a>
</div>
</nav>
<div class="content-container">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="width: 95%;">
<h1 style="margin-bottom: 1rem;">About SpotNotes.app</h1>
<p style="line-height: 1.6; color: #555;">
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.
</p>
<p style="line-height: 1.6; color: #555; margin-top: 10px;">
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.
</p>
<hr style="margin: 30px 0; border: 0; border-top: 1px solid #eee;">
<h2 style="margin-bottom: 1rem;">Key Features</h2>
<div style="display: flex; flex-wrap: wrap; gap: 20px;">
<div style="flex: 1 1 300px;">
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">📍 Location-Based Notes</h3>
<p style="font-size: 0.9rem; color: #666;">Create notes that are tied to exact map coordinates. View them in context with the surrounding area.</p>
</div>
<div style="flex: 1 1 300px;">
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🌍 Public & Private Notepads</h3>
<p style="font-size: 0.9rem; color: #666;">Organize your notes into notepads. Keep them private for personal use or make them public to share with the community.</p>
</div>
<div style="flex: 1 1 300px;">
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">❤️ Favorites</h3>
<p style="font-size: 0.9rem; color: #666;">Found a notepad you like? Add it to your favorites for quick and easy access later.</p>
</div>
<div style="flex: 1 1 300px;">
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🛡️ Community Driven</h3>
<p style="font-size: 0.9rem; color: #666;">Help keep the map clean by reporting inappropriate content. We believe in a safe and useful environment for everyone.</p>
</div>
</div>
<hr style="margin: 30px 0; border: 0; border-top: 1px solid #eee;">
<h2 style="margin-bottom: 1rem;">Frequently Asked Questions</h2>
<div style="margin-bottom: 20px;">
<h4 style="margin-bottom: 5px;">Is SpotNotes free to use?</h4>
<p style="color: #555;">Yes, SpotNotes is completely free. You can create unlimited notes and notepads.</p>
</div>
<div style="margin-bottom: 20px;">
<h4 style="margin-bottom: 5px;">Do I need an account to view notes?</h4>
<p style="color: #555;">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.</p>
</div>
<div style="margin-bottom: 20px;">
<h4 style="margin-bottom: 5px;">Can I collaborate on notepads?</h4>
<p style="color: #555;">Currently, public notepads can be viewed and edited by anyone.</p>
</div>
<div style="margin-top: 40px; text-align: center;">
<a href="/" class="btn btn-primary" style="text-decoration: none; display: inline-block; width: auto; padding: 12px 30px;">Start Exploring</a>
</div>
</div>
<script src="/assets/js/nav.js"></script>
</body>
</html>

48
static/admin.html Normal file
View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - SpotNotes.app</title>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/auth.css">
<link rel="stylesheet" href="/assets/css/admin.css">
<link rel="icon" href="favicon.ico" type="image/x-icon" />
</head>
<body class="page-body">
<div class="content-container">
<h1>Admin Panel</h1>
<!-- Stats Dashboard -->
<div id="admin-stats" class="stats-container">
<!-- Stats injected by JS -->
</div>
<div class="admin-section">
<h2 class="section-title">
Users <span class="toggle-icon">▼</span>
</h2>
<input type="text" id="user-search" class="admin-search" placeholder="Search users by email or name..." style="margin: 10px; width: 95%; padding: 8px;">
<div id="user-list" class="section-content">
<p>Loading users...</p>
</div>
</div>
<div class="admin-section">
<h2 class="section-title">Notepads <span class="toggle-icon">▼</span></h2>
<div id="notepad-list" class="section-content">
<p>Select a user to see their notepads.</p>
</div>
</div>
<div class="admin-section">
<h2 class="section-title">Notes <span class="toggle-icon">▼</span></h2>
<div id="note-list" class="section-content">
<p>Select a notepad to see its notes.</p>
</div>
</div>
</div>
<script src="/assets/js/admin.js"></script>
</body>
</html>

102
static/assets/css/admin.css Normal file
View file

@ -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;
}

View file

@ -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; }

View file

@ -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; }

99
static/assets/css/map.css Normal file
View file

@ -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;
}

View file

@ -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; }

View file

@ -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;
}
}

View file

@ -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%);
}
}

View file

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

120
static/assets/js/actions.js Normal file
View file

@ -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');
}

257
static/assets/js/admin.js Normal file
View file

@ -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 = '<p>Loading users...</p>';
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 = '<p style="color:red;">Error loading users.</p>';
}
}
function renderUsers(users) {
const userListDiv = document.getElementById('user-list');
userListDiv.innerHTML = '';
if (users.length === 0) {
userListDiv.innerHTML = '<p style="padding:10px;">No users found.</p>';
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 ? '' : `<br> <small>IP: ${escapeHtml(user.last_ip || 'Unknown')}</small>`;
const infoDiv = document.createElement('div');
infoDiv.innerHTML = `<strong>${escapeHtml(user.email)}</strong> (${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 = '<p>Loading notepads...</p>';
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 = `<strong>${escapeHtml(notepad.name)}</strong> (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 = '<p style="color:red;">Error loading notepads.</p>';
}
}
async function loadNotes(notepadId) {
const noteListDiv = document.getElementById('note-list');
noteListDiv.innerHTML = '<p>Loading notes...</p>';
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 = `<strong>${escapeHtml(note.title)}</strong>: ${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 = '<p style="color:red;">Error loading notes.</p>';
}
}
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 = `
<div class="stat-card"><h3>${stats.user_count}</h3><p>Users</p></div>
<div class="stat-card"><h3>${stats.notepad_count}</h3><p>Notepads</p></div>
<div class="stat-card"><h3>${stats.note_count}</h3><p>Notes</p></div>
`;
} 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

59
static/assets/js/auth.js Normal file
View file

@ -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');
}
});
}
});

1024
static/assets/js/index.js Normal file

File diff suppressed because it is too large Load diff

42
static/assets/js/nav.js Normal file
View file

@ -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';
}
}

View file

@ -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);
}
});

38
static/contact.html Normal file
View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact - SpotNotes</title>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/navbar.css">
<link rel="stylesheet" href="/assets/css/auth.css">
<link rel="icon" href="favicon.ico" type="image/x-icon" />
</head>
<body class="page-body">
<nav class="navbar">
<a href="/" class="brand">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="height: 44px;">
</a>
<div class="nav-links">
<a href="/login.html" id="link-login">Login</a>
<a href="/about.html">About</a>
<a href="/contact.html">Contact</a>
<a href="#" id="link-logout" style="display: none;">Logout</a>
</div>
</nav>
<div class="content-container">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="width: 95%;">
<h1 style="margin-bottom: 1rem;">Contact</h1>
<p style="margin-bottom: 2rem;">
<a href="mailto:benjimanmiller@gmail.com" style="font-size: 1.2rem;">benjimanmiller@gmail.com</a>
</p>
<div class="links" style="text-align: left;">
<a href="/" class="btn btn-primary" style="display: inline-block; width: auto; text-decoration: none; color: white;">&larr; Back to Map</a>
</div>
</div>
<script src="/assets/js/nav.js"></script>
</body>
</html>

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

185
static/index.html Normal file
View file

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SpotNotes.app - Location Based Notes</title>
<!-- Leaflet CSS for the map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/navbar.css">
<link rel="stylesheet" href="/assets/css/map.css">
<link rel="stylesheet" href="/assets/css/sidebar.css">
<link rel="stylesheet" href="/assets/css/auth.css">
<link rel="stylesheet" href="/assets/css/modals.css">
<link rel="icon" href="favicon.ico" type="image/x-icon" />
</head>
<body class="page-body">
<nav class="navbar">
<a href="/" class="brand">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="height: 44px;">
</a>
<div class="nav-search">
<form id="search-form" style="display: flex; width: 100%;">
<input type="text" id="search-input" placeholder="Search notes or location...">
<button type="submit" id="search-btn">🔍</button>
</form>
</div>
<div class="nav-links">
<a href="/login.html" id="link-login">Login</a>
<a href="/about.html">About</a>
<a href="/contact.html">Contact</a>
<a href="#" id="link-logout" style="display: none;">Logout</a>
</div>
</nav>
<!-- Full Screen Map Container -->
<div id="map"></div>
<!-- Center Crosshair -->
<div id="map-crosshair"></div>
<!-- Right Collapsible Panel -->
<div class="sidebar" id="sidebar">
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Toggle Notepads">
&gt; <!-- Simple arrow icon placeholder -->
</button>
<div class="sidebar-content">
<div class="sidebar-header">
<h2>Notepads</h2>
<button class="btn-new-pad" id="btn-create-pad" title="Create New Pad">+</button>
</div>
<!-- Center Map on User Button -->
<button id="btn-center-user" class="btn-center-location" style="display: none;">Target My Location</button>
<div id="sidebar-body">
<!-- Notepad items will be injected here by JS -->
</div>
</div>
</div>
<!-- Create Notepad Modal -->
<div id="modal-create-pad" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h3>Create New Notepad</h3>
<form id="form-create-pad">
<div class="form-group">
<label>Name</label>
<input type="text" id="pad-name" required>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="pad-public" style="width:auto;"> Make Public
</label>
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
</div>
</div>
<!-- Edit Notepad Modal -->
<div id="modal-edit-pad" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h3>Edit Notepad</h3>
<form id="form-edit-pad">
<input type="hidden" id="pad-id-edit">
<div class="form-group">
<label>Name</label>
<input type="text" id="pad-name-edit" required>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="pad-public-edit" style="width:auto;"> Make Public
</label>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</div>
</div>
<!-- Share Settings Modal -->
<div id="modal-share-settings" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h3>Share Settings for <span id="share-pad-name"></span></h3>
<h4>Current Collaborators</h4>
<div id="collaborators-list" style="margin-bottom: 20px;">
<!-- Collaborators will be injected here -->
</div>
<h4>Add New Collaborator</h4>
<form id="form-add-collaborator">
<input type="hidden" id="share-pad-id">
<div class="form-group">
<label>User Email</label>
<input type="email" id="collaborator-email" required>
</div>
<div class="form-group">
<label>Permission</label>
<select id="collaborator-permission">
<option value="read">Read-Only</option>
<option value="write">Read & Write</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Add Collaborator</button>
</form>
</div>
</div>
<!-- Create Note Modal -->
<div id="modal-create-note" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h3>Add Note</h3>
<p style="font-size:0.8rem; color:#666; margin-bottom:10px;">Note will be placed at the center of the map.</p>
<form id="form-create-note">
<div class="form-group">
<label>Title</label>
<input type="text" id="note-title" required>
</div>
<div class="form-group">
<label>Content</label>
<textarea id="note-content" required rows="4" style="width: 100%;"></textarea>
</div>
<button type="submit" class="btn btn-primary">Add Note</button>
</form>
</div>
</div>
<!-- Edit Note Modal -->
<div id="modal-edit-note" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h3>Edit Note</h3>
<form id="form-edit-note">
<input type="hidden" id="note-id-edit">
<div class="form-group">
<label>Title</label>
<input type="text" id="note-title-edit" required>
</div>
<div class="form-group">
<label>Content</label>
<textarea id="note-content-edit" required rows="4" style="width: 100%;"></textarea>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="/assets/js/nav.js"></script>
<script src="/assets/js/index.js"></script>
<script src="/assets/js/actions.js"></script>
<script src="/assets/js/search.js"></script>
</body>
</html>

47
static/login.html Normal file
View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - SpotNotes.app</title>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/navbar.css">
<link rel="stylesheet" href="/assets/css/auth.css">
<link rel="icon" href="favicon.ico" type="image/x-icon" />
</head>
<body class="page-body">
<nav class="navbar">
<a href="/" class="brand">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="height: 44px;">
</a>
<div class="nav-links">
<a href="/login.html" id="link-login">Login</a>
<a href="/about.html">About</a>
<a href="/contact.html">Contact</a>
<a href="#" id="link-logout" style="display: none;">Logout</a>
</div>
</nav>
<div class="auth-container">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="width: 95%;">
<h2 style="text-align:center; margin-bottom:20px;">Login</h2>
<form id="login-form">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" required placeholder="Enter your email">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" required placeholder="Enter your password">
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
<div class="links">
<a href="/register.html">Need an account? Register</a> |
<a href="/">Back to Map</a>
</div>
</div>
<script src="/assets/js/auth.js"></script>
</body>
</html>

55
static/register.html Normal file
View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - SpotNotes.app</title>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/navbar.css">
<link rel="stylesheet" href="/assets/css/auth.css">
<link rel="icon" href="favicon.ico" type="image/x-icon" />
</head>
<body class="page-body">
<nav class="navbar">
<a href="/" class="brand">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="height: 44px;">
</a>
<div class="nav-links">
<a href="/login.html" id="link-login">Login</a>
<a href="/about.html">About</a>
<a href="/contact.html">Contact</a>
<a href="#" id="link-logout" style="display: none;">Logout</a>
</div>
</nav>
<div class="auth-container">
<img src="/assets/images/spotnotes.png" alt="SpotNotes.app" style="width: 95%;">
<h2 style="text-align:center; margin-bottom:20px;">Create Account</h2>
<form id="register-form">
<div class="form-group">
<label for="first-name">First Name</label>
<input type="text" id="first-name" required placeholder="First Name">
</div>
<div class="form-group">
<label for="last-name">Last Name</label>
<input type="text" id="last-name" required placeholder="Last Name">
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" required placeholder="name@example.com">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" required placeholder="Create a password">
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
<div class="links">
<a href="/login.html">Already have an account? Login</a> |
<a href="/">Back to Map</a>
</div>
</div>
<script src="/assets/js/auth.js"></script>
</body>
</html>