intial commit
This commit is contained in:
commit
6ec01760eb
50 changed files with 4586 additions and 0 deletions
213
.gitignore
vendored
Normal file
213
.gitignore
vendored
Normal 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
40
GEMINI.md
Normal 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
1
Procfile
Normal file
|
|
@ -0,0 +1 @@
|
|||
web: gunicorn --worker-tmp-dir /dev/shm --config gunicorn_config.py "app:create_app()"
|
||||
101
README.md
Normal file
101
README.md
Normal 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
34
app.py
Normal 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
13
blueprints/__init__.py
Normal 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
126
blueprints/admin.py
Normal 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
391
blueprints/api.py
Normal 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
72
blueprints/auth.py
Normal 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
79
blueprints/search.py
Normal 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
|
||||
11
blueprints/static_routes.py
Normal file
11
blueprints/static_routes.py
Normal 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
9
config.py
Normal 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
5
extensions.py
Normal 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
2
gunicorn_config.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
bind = "0.0.0.0:8081"
|
||||
workers = 2
|
||||
7
models/banned_ip.py
Normal file
7
models/banned_ip.py
Normal 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
25
models/collaborator.py
Normal 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
10
models/favorite.py
Normal 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
22
models/note.py
Normal 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
21
models/notepad.py
Normal 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
29
models/user.py
Normal 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
51
notes/birding.csv
Normal 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.
|
51
notes/conspiracy_locations.csv
Normal file
51
notes/conspiracy_locations.csv
Normal 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
|
||||
|
101
notes/general_public.csv
Normal file
101
notes/general_public.csv
Normal 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
|
||||
|
51
notes/haunted-oklahoma.csv
Normal file
51
notes/haunted-oklahoma.csv
Normal 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
51
notes/haunted-usa.csv
Normal 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
51
notes/route66.csv
Normal 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
6
requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
Flask
|
||||
requests
|
||||
Flask-Cors
|
||||
gunicorn
|
||||
Flask-SQLAlchemy
|
||||
python-dotenv
|
||||
80
seed_notes.py
Normal file
80
seed_notes.py
Normal 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
88
static/about.html
Normal 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
48
static/admin.html
Normal 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
102
static/assets/css/admin.css
Normal 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;
|
||||
}
|
||||
37
static/assets/css/auth.css
Normal file
37
static/assets/css/auth.css
Normal 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; }
|
||||
40
static/assets/css/base.css
Normal file
40
static/assets/css/base.css
Normal 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
99
static/assets/css/map.css
Normal 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;
|
||||
}
|
||||
33
static/assets/css/modals.css
Normal file
33
static/assets/css/modals.css
Normal 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; }
|
||||
94
static/assets/css/navbar.css
Normal file
94
static/assets/css/navbar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
167
static/assets/css/sidebar.css
Normal file
167
static/assets/css/sidebar.css
Normal 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%);
|
||||
}
|
||||
}
|
||||
339
static/assets/css/styles.css
Normal file
339
static/assets/css/styles.css
Normal 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;
|
||||
}
|
||||
BIN
static/assets/images/spotnotes.png
Normal file
BIN
static/assets/images/spotnotes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
120
static/assets/js/actions.js
Normal file
120
static/assets/js/actions.js
Normal 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
257
static/assets/js/admin.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
59
static/assets/js/auth.js
Normal file
59
static/assets/js/auth.js
Normal 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
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
42
static/assets/js/nav.js
Normal 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';
|
||||
}
|
||||
}
|
||||
59
static/assets/js/search.js
Normal file
59
static/assets/js/search.js
Normal 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
38
static/contact.html
Normal 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;">← Back to Map</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/js/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
185
static/index.html
Normal file
185
static/index.html
Normal 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">
|
||||
> <!-- 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">×</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">×</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">×</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">×</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">×</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
47
static/login.html
Normal 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
55
static/register.html
Normal 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>
|
||||
Loading…
Reference in a new issue