diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc
index 896bb4f..bad71c0 100644
Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ
diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index d79d6a7..f77a9c1 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc
index 8cf22af..24bded5 100644
Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ
diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc
index a1b4aa7..ca55c11 100644
Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ
diff --git a/config/settings.py b/config/settings.py
index 291d043..1e5f14d 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -150,9 +150,12 @@ STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
- BASE_DIR / 'static',
- BASE_DIR / 'assets',
- BASE_DIR / 'node_modules',
+ path for path in [
+ BASE_DIR / 'static',
+ BASE_DIR / 'assets',
+ BASE_DIR / 'node_modules',
+ ]
+ if path.exists()
]
# Email
@@ -180,3 +183,8 @@ if EMAIL_USE_SSL:
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+
+# Media uploads
+MEDIA_URL = "/media/"
+MEDIA_ROOT = BASE_DIR / "media"
diff --git a/config/urls.py b/config/urls.py
index bcfc074..1a48858 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -27,3 +27,4 @@ urlpatterns = [
if settings.DEBUG:
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc
index 3f553f6..4298c93 100644
Binary files a/core/__pycache__/__init__.cpython-311.pyc and b/core/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 5e8987a..095ec4f 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/apps.cpython-311.pyc b/core/__pycache__/apps.cpython-311.pyc
index 2fa4a49..2bcf30e 100644
Binary files a/core/__pycache__/apps.cpython-311.pyc and b/core/__pycache__/apps.cpython-311.pyc differ
diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc
index 75bf223..b1f708f 100644
Binary files a/core/__pycache__/context_processors.cpython-311.pyc and b/core/__pycache__/context_processors.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
new file mode 100644
index 0000000..6990f96
Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ
diff --git a/core/__pycache__/image_tools.cpython-311.pyc b/core/__pycache__/image_tools.cpython-311.pyc
new file mode 100644
index 0000000..1d15628
Binary files /dev/null and b/core/__pycache__/image_tools.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index a251b5f..ca61d90 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f705988..1c04759 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 2f0989c..c1cdde3 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 8c38f3f..2676b11 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -1,3 +1,36 @@
from django.contrib import admin
-# Register your models here.
+from .models import PropertyEntry, PropertyFlag, PropertySuggestion
+
+
+class PropertySuggestionInline(admin.TabularInline):
+ model = PropertySuggestion
+ extra = 0
+ readonly_fields = ("created_at",)
+
+
+class PropertyFlagInline(admin.TabularInline):
+ model = PropertyFlag
+ extra = 0
+ readonly_fields = ("created_at",)
+
+
+@admin.register(PropertyEntry)
+class PropertyEntryAdmin(admin.ModelAdmin):
+ list_display = ("id", "address", "listing_type", "source", "has_gps_data", "flag_count", "created_at")
+ list_filter = ("listing_type", "source", "has_gps_data", "is_flagged")
+ search_fields = ("address", "phone", "email", "extracted_text")
+ readonly_fields = ("created_at", "updated_at")
+ inlines = [PropertySuggestionInline, PropertyFlagInline]
+
+
+@admin.register(PropertySuggestion)
+class PropertySuggestionAdmin(admin.ModelAdmin):
+ list_display = ("id", "property_entry", "address", "email", "created_at")
+ search_fields = ("address", "phone", "email", "note")
+
+
+@admin.register(PropertyFlag)
+class PropertyFlagAdmin(admin.ModelAdmin):
+ list_display = ("id", "property_entry", "reason", "created_at")
+ search_fields = ("reason",)
diff --git a/core/forms.py b/core/forms.py
new file mode 100644
index 0000000..643b821
--- /dev/null
+++ b/core/forms.py
@@ -0,0 +1,91 @@
+from django import forms
+
+from .models import PropertyEntry, PropertyFlag, PropertySuggestion
+
+
+class BootstrapFormMixin:
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for field in self.fields.values():
+ widget = field.widget
+ if widget.__class__.__name__ == "HiddenInput":
+ continue
+ current = widget.attrs.get("class", "")
+ if widget.__class__.__name__ == "Select":
+ widget.attrs["class"] = f"form-select {current}".strip()
+ elif widget.__class__.__name__ == "Textarea":
+ widget.attrs["class"] = f"form-control {current}".strip()
+ else:
+ widget.attrs["class"] = f"form-control {current}".strip()
+
+
+class PropertyLocationForm(BootstrapFormMixin, forms.ModelForm):
+ latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
+ longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
+
+ class Meta:
+ model = PropertyEntry
+ fields = ["address", "latitude", "longitude", "phone", "email", "listing_type"]
+ widgets = {
+ "address": forms.TextInput(attrs={"placeholder": "Street, area, city"}),
+ "phone": forms.TextInput(attrs={"placeholder": "+34 600 000 000"}),
+ "email": forms.EmailInput(attrs={"placeholder": "owner@example.com"}),
+ "listing_type": forms.Select(),
+ }
+
+ def clean(self):
+ cleaned = super().clean()
+ address = cleaned.get("address")
+ latitude = cleaned.get("latitude")
+ longitude = cleaned.get("longitude")
+ if not address and (latitude is None or longitude is None):
+ raise forms.ValidationError("Add an address or allow location so this property can be placed on the pinboard.")
+ return cleaned
+
+
+class PropertyPhotoForm(BootstrapFormMixin, forms.ModelForm):
+ class Meta:
+ model = PropertyEntry
+ fields = ["photo", "address", "phone", "email", "listing_type"]
+ widgets = {
+ "address": forms.TextInput(attrs={"placeholder": "Optional if the photo contains GPS or visible text"}),
+ "phone": forms.TextInput(attrs={"placeholder": "Optional contact phone"}),
+ "email": forms.EmailInput(attrs={"placeholder": "Optional contact email"}),
+ "listing_type": forms.Select(),
+ }
+
+ def clean_photo(self):
+ photo = self.cleaned_data.get("photo")
+ if not photo:
+ raise forms.ValidationError("Choose a property photo to upload.")
+ if photo.size > 12 * 1024 * 1024:
+ raise forms.ValidationError("Please upload an image under 12MB for this MVP.")
+ return photo
+
+
+class PropertySuggestionForm(BootstrapFormMixin, forms.ModelForm):
+ class Meta:
+ model = PropertySuggestion
+ fields = ["address", "phone", "email", "listing_type", "note"]
+ widgets = {
+ "address": forms.TextInput(attrs={"placeholder": "Correct or missing address"}),
+ "phone": forms.TextInput(attrs={"placeholder": "Correct or missing phone"}),
+ "email": forms.EmailInput(attrs={"placeholder": "Correct or missing email"}),
+ "listing_type": forms.Select(),
+ "note": forms.Textarea(attrs={"rows": 3, "placeholder": "What should be updated?"}),
+ }
+
+ def clean(self):
+ cleaned = super().clean()
+ if not any(cleaned.get(field) for field in self.fields):
+ raise forms.ValidationError("Add at least one suggested detail.")
+ return cleaned
+
+
+class PropertyFlagForm(BootstrapFormMixin, forms.ModelForm):
+ class Meta:
+ model = PropertyFlag
+ fields = ["reason"]
+ widgets = {
+ "reason": forms.TextInput(attrs={"placeholder": "Duplicate, spam, private info, already removed..."}),
+ }
diff --git a/core/image_tools.py b/core/image_tools.py
new file mode 100644
index 0000000..dc62485
--- /dev/null
+++ b/core/image_tools.py
@@ -0,0 +1,104 @@
+import io
+import shutil
+import subprocess
+import tempfile
+from decimal import Decimal
+from pathlib import Path
+
+from django.core.files.base import ContentFile
+from PIL import Image, UnidentifiedImageError
+
+GPS_TAG = 34853
+MAX_BYTES = 256 * 1024
+
+
+def _ratio_to_float(value):
+ try:
+ return float(value.numerator) / float(value.denominator)
+ except AttributeError:
+ return float(value)
+
+
+def _gps_to_decimal(parts, ref):
+ degrees = _ratio_to_float(parts[0])
+ minutes = _ratio_to_float(parts[1])
+ seconds = _ratio_to_float(parts[2])
+ result = degrees + minutes / 60 + seconds / 3600
+ if ref in ("S", "W"):
+ result = -result
+ return Decimal(str(round(result, 6)))
+
+
+def extract_gps(image):
+ try:
+ exif = image.getexif()
+ gps = exif.get_ifd(GPS_TAG) if exif else None
+ if not gps:
+ return None, None
+ lat = gps.get(2)
+ lat_ref = gps.get(1)
+ lng = gps.get(4)
+ lng_ref = gps.get(3)
+ if not (lat and lat_ref and lng and lng_ref):
+ return None, None
+ return _gps_to_decimal(lat, lat_ref), _gps_to_decimal(lng, lng_ref)
+ except Exception:
+ return None, None
+
+
+def compress_image(uploaded_file, base_name="property"):
+ try:
+ uploaded_file.seek(0)
+ image = Image.open(uploaded_file)
+ image.load()
+ except (UnidentifiedImageError, OSError):
+ return None, "The uploaded file is not a readable image."
+
+ latitude, longitude = extract_gps(image)
+ image = image.convert("RGB")
+ image.thumbnail((1600, 1600))
+
+ quality = 86
+ output = io.BytesIO()
+ while quality >= 45:
+ output.seek(0)
+ output.truncate(0)
+ image.save(output, format="JPEG", optimize=True, progressive=True, quality=quality)
+ if output.tell() <= MAX_BYTES:
+ break
+ quality -= 7
+
+ while output.tell() > MAX_BYTES and image.width > 640 and image.height > 640:
+ image.thumbnail((int(image.width * 0.82), int(image.height * 0.82)))
+ output.seek(0)
+ output.truncate(0)
+ image.save(output, format="JPEG", optimize=True, progressive=True, quality=max(quality, 45))
+
+ filename = f"{Path(base_name).stem or 'property'}-small.jpg"
+ return {
+ "file": ContentFile(output.getvalue(), name=filename),
+ "latitude": latitude,
+ "longitude": longitude,
+ "size": output.tell(),
+ }, ""
+
+
+def ocr_text_best_effort(uploaded_file):
+ """Use locally installed system OCR only. Returns blank when unavailable."""
+ if not shutil.which("tesseract"):
+ return ""
+ try:
+ uploaded_file.seek(0)
+ with tempfile.NamedTemporaryFile(suffix=Path(uploaded_file.name).suffix or ".jpg") as tmp:
+ tmp.write(uploaded_file.read())
+ tmp.flush()
+ result = subprocess.run(
+ ["tesseract", tmp.name, "stdout", "--psm", "6"],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=8,
+ )
+ return " ".join(result.stdout.split())[:1200]
+ except Exception:
+ return ""
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..4105551
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -0,0 +1,68 @@
+# Generated by Django 5.2.7 on 2026-06-04 16:35
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PropertyEntry',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('source', models.CharField(choices=[('current_location', 'Current location'), ('photo', 'Photo upload')], max_length=32)),
+ ('address', models.CharField(blank=True, max_length=255)),
+ ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
+ ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
+ ('phone', models.CharField(blank=True, max_length=40)),
+ ('email', models.EmailField(blank=True, max_length=254)),
+ ('listing_type', models.CharField(choices=[('sale', 'For sale'), ('rental', 'For rent'), ('unknown', 'Not sure')], default='unknown', max_length=20)),
+ ('photo', models.ImageField(blank=True, upload_to='properties/%Y/%m/')),
+ ('extracted_text', models.TextField(blank=True)),
+ ('has_gps_data', models.BooleanField(default=False)),
+ ('idealista_url', models.URLField(blank=True)),
+ ('is_flagged', models.BooleanField(default=False)),
+ ('flag_count', models.PositiveIntegerField(default=0)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'verbose_name_plural': 'property entries',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='PropertyFlag',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('reason', models.CharField(max_length=160)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('property_entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flags', to='core.propertyentry')),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='PropertySuggestion',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('address', models.CharField(blank=True, max_length=255)),
+ ('phone', models.CharField(blank=True, max_length=40)),
+ ('email', models.EmailField(blank=True, max_length=254)),
+ ('listing_type', models.CharField(blank=True, choices=[('sale', 'For sale'), ('rental', 'For rent'), ('unknown', 'Not sure')], max_length=20)),
+ ('note', models.TextField(blank=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('property_entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='suggestions', to='core.propertyentry')),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc
new file mode 100644
index 0000000..bc7e89b
Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc
index 7995815..f4a0b7d 100644
Binary files a/core/migrations/__pycache__/__init__.cpython-311.pyc and b/core/migrations/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 71a8362..88e8c7f 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,3 +1,73 @@
+from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
+from django.urls import reverse
-# Create your models here.
+
+class PropertyEntry(models.Model):
+ class ListingType(models.TextChoices):
+ SALE = "sale", "For sale"
+ RENTAL = "rental", "For rent"
+ UNKNOWN = "unknown", "Not sure"
+
+ class Source(models.TextChoices):
+ CURRENT_LOCATION = "current_location", "Current location"
+ PHOTO = "photo", "Photo upload"
+
+ source = models.CharField(max_length=32, choices=Source.choices)
+ address = models.CharField(max_length=255, blank=True)
+ latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
+ longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
+ phone = models.CharField(max_length=40, blank=True)
+ email = models.EmailField(blank=True)
+ listing_type = models.CharField(max_length=20, choices=ListingType.choices, default=ListingType.UNKNOWN)
+ photo = models.ImageField(upload_to="properties/%Y/%m/", blank=True)
+ extracted_text = models.TextField(blank=True)
+ has_gps_data = models.BooleanField(default=False)
+ idealista_url = models.URLField(blank=True)
+ is_flagged = models.BooleanField(default=False)
+ flag_count = models.PositiveIntegerField(default=0)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+ verbose_name_plural = "property entries"
+
+ def __str__(self):
+ label = self.address or f"{self.get_source_display()} #{self.pk}"
+ return f"{label} ({self.get_listing_type_display()})"
+
+ def get_absolute_url(self):
+ return reverse("property_detail", args=[self.pk])
+
+ @property
+ def has_location(self):
+ return self.latitude is not None and self.longitude is not None
+
+
+class PropertySuggestion(models.Model):
+ property_entry = models.ForeignKey(PropertyEntry, on_delete=models.CASCADE, related_name="suggestions")
+ address = models.CharField(max_length=255, blank=True)
+ phone = models.CharField(max_length=40, blank=True)
+ email = models.EmailField(blank=True)
+ listing_type = models.CharField(max_length=20, choices=PropertyEntry.ListingType.choices, blank=True)
+ note = models.TextField(blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+
+ def __str__(self):
+ return f"Suggestion for #{self.property_entry_id}"
+
+
+class PropertyFlag(models.Model):
+ property_entry = models.ForeignKey(PropertyEntry, on_delete=models.CASCADE, related_name="flags")
+ reason = models.CharField(max_length=160)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+
+ def __str__(self):
+ return f"Flag for #{self.property_entry_id}: {self.reason}"
diff --git a/core/templates/base.html b/core/templates/base.html
index 1e7e5fb..30b2e8b 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,11 +1,13 @@
+{% load static %}
- {% block title %}Knowledge Base{% endblock %}
+
+ {% block title %}{{ page_title|default:"NearbyNest" }}{% endblock %}
+
{% if project_description %}
-
{% endif %}
@@ -13,13 +15,57 @@
{% endif %}
- {% load static %}
+
+
+
+
{% block head %}{% endblock %}
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+
{{ message }}
+ {% endfor %}
+
+ {% endif %}
+
{% block content %}{% endblock %}
+
+
+
+
+
+ {% block scripts %}{% endblock %}
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index faec813..6cbc919 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,145 +1,101 @@
{% extends "base.html" %}
-{% block title %}{{ project_name }}{% endblock %}
-
-{% block head %}
-
-
-
-
-{% endblock %}
-
{% block content %}
-
-
Analyzing your requirements and generating your app…
-
-
Loading…
+
+
+
+
+
+
+
Mobile-first PWA · property pinboard
+
Spot a sale or rental nearby. Pin it in seconds.
+
NearbyNest lets anyone add a public property sighting from their current location or an uploaded photo, then browse fresh entries by distance or recency.
+
+
+ Location Notifications Photos
+
+
+
+
+
+
+
+
+
+
+
+
Live MVP
+
Public nearby list
+
{{ total_entries }} entries · {{ photo_entries }} photo uploads
+
Add current location →
+
+
+
+
+
- AppWizzy AI is collecting your requirements and applying the first changes.
- This page will refresh automatically as the plan is implemented.
-
- Runtime: Django {{ django_version }} · Python {{ python_version }}
- — UTC {{ current_time|date:"Y-m-d H:i:s" }}
-
-
+
+
+
+
+
+
+
+ 📍
+ Pin by location
+ Grant browser location permission, then submit an address or GPS coordinates with optional contact details.
+ Pin a property
+
+
+
+
+ 📷
+ Upload photo
+ Save only a compressed small image, check EXIF GPS, and attempt local OCR when a system OCR tool exists.
+ Upload photo
+
+
+
+
+ 🧭
+ Browse & improve
+ Sort the public list by recency or distance, flag questionable entries, or suggest missing details.
+ View public list
+
+
+
+
+
+
+
+
+
+
+
Latest sightings
+
Fresh public property pins
+
+
See all
+
+ {% if recent_entries %}
+
+ {% for entry in recent_entries %}
+
+ {% include "core/partials/property_card.html" with entry=entry %}
+
+ {% endfor %}
+
+ {% else %}
+
+
No properties yet
+
Be the first to add a current-location pin or photo upload.
+
Create first pin
+
+ {% endif %}
+
+
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
-
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/core/templates/core/onboarding.html b/core/templates/core/onboarding.html
new file mode 100644
index 0000000..aee7607
--- /dev/null
+++ b/core/templates/core/onboarding.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+
Permission onboarding
+
Prepare your device for fast property pins.
+
These browser prompts mimic the future mobile app permissions: location, notifications, and photo access where supported.
+
+
+
+
📍 Location Used to add a property at your current position and sort entries by distance.
+
Grant location
+
Not requested yet
+
+
+
🔔 Notifications Future-ready prompt for review alerts and nearby updates.
+
Grant notifications
+
Not requested yet
+
+
+
🖼️ Photos Web browsers ask when you choose a file; mobile apps can request photo library permission directly.
+
Choose a photo
+
Requested by the file picker on supported platforms
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/partials/property_card.html b/core/templates/core/partials/property_card.html
new file mode 100644
index 0000000..e9db53d
--- /dev/null
+++ b/core/templates/core/partials/property_card.html
@@ -0,0 +1,24 @@
+
+ {% if entry.photo %}
+
+ {% else %}
+ ⌂
+ {% endif %}
+
+
+ {{ entry.get_listing_type_display }}
+ {{ entry.created_at|timesince }} ago
+
+
{{ entry.address|default:"Location-only property" }}
+
+ {{ entry.get_source_display }}{% if entry.has_gps_data %} · GPS from photo{% endif %}{% if entry.distance_km is not None %} · {{ entry.distance_km|floatformat:1 }} km{% endif %}
+
+
+ {% if entry.phone %}Phone {% endif %}
+ {% if entry.email %}Email {% endif %}
+ {% if entry.idealista_url %}Idealista link {% endif %}
+ {% if not entry.phone and not entry.email %}Needs details {% endif %}
+
+
+
+
diff --git a/core/templates/core/property_detail.html b/core/templates/core/property_detail.html
new file mode 100644
index 0000000..835a4c4
--- /dev/null
+++ b/core/templates/core/property_detail.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+
+
+ {% if entry.photo %}
+
+ {% endif %}
+
+
{{ entry.get_listing_type_display }}
+
{{ entry.address|default:"Location-only property" }}
+
Added {{ entry.created_at|date:"M j, Y H:i" }} · {{ entry.get_source_display }}
+
+
Phone {{ entry.phone|default:"Missing" }}
+
Email {{ entry.email|default:"Missing" }}
+
GPS {% if entry.has_location %}{{ entry.latitude }}, {{ entry.longitude }}{% else %}Missing{% endif %}
+
Photo GPS {{ entry.has_gps_data|yesno:"Detected,Not detected" }}
+
+ {% if entry.extracted_text %}
+
Text spotted in photo {{ entry.extracted_text }}
+ {% endif %}
+ {% if entry.idealista_url %}
+
Open best-effort Idealista search
+ {% endif %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/property_form_location.html b/core/templates/core/property_form_location.html
new file mode 100644
index 0000000..02696d7
--- /dev/null
+++ b/core/templates/core/property_form_location.html
@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+{% endblock %}
diff --git a/core/templates/core/property_form_photo.html b/core/templates/core/property_form_photo.html
new file mode 100644
index 0000000..3a0c29e
--- /dev/null
+++ b/core/templates/core/property_form_photo.html
@@ -0,0 +1,29 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+{% endblock %}
diff --git a/core/templates/core/property_list.html b/core/templates/core/property_list.html
new file mode 100644
index 0000000..bc2d2ea
--- /dev/null
+++ b/core/templates/core/property_list.html
@@ -0,0 +1,50 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+
+
Public list
+
Nearby property sightings
+
Sort by newest posts or allow location to estimate the closest entries.
+
+
+
+
+
+
+ {% if entries %}
+
+ {% for entry in entries %}
+
+ {% include "core/partials/property_card.html" with entry=entry %}
+
+ {% endfor %}
+
+ {% else %}
+
+
No visible properties yet
+
Add the first public pin from your location or upload a compressed property photo.
+
Create first entry
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..c9e0db4 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,7 +1,23 @@
from django.urls import path
-from .views import home
+from .views import (
+ add_location_property,
+ add_photo_property,
+ flag_property,
+ home,
+ onboarding,
+ property_detail,
+ property_list,
+ suggest_property_update,
+)
urlpatterns = [
path("", home, name="home"),
+ path("onboarding/", onboarding, name="onboarding"),
+ path("properties/", property_list, name="property_list"),
+ path("properties/add/location/", add_location_property, name="add_location_property"),
+ path("properties/add/photo/", add_photo_property, name="add_photo_property"),
+ path("properties/
/", property_detail, name="property_detail"),
+ path("properties//suggest/", suggest_property_update, name="suggest_property_update"),
+ path("properties//flag/", flag_property, name="flag_property"),
]
diff --git a/core/views.py b/core/views.py
index c9aed12..2a31551 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,25 +1,178 @@
-import os
-import platform
+import math
+from urllib.parse import quote_plus
-from django import get_version as django_version
-from django.shortcuts import render
-from django.utils import timezone
+from django.contrib import messages
+from django.db import transaction
+from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
+
+from .forms import PropertyFlagForm, PropertyLocationForm, PropertyPhotoForm, PropertySuggestionForm
+from .image_tools import compress_image, ocr_text_best_effort
+from .models import PropertyEntry
+
+
+def build_idealista_url(entry):
+ query = entry.address or " ".join(filter(None, [entry.phone, entry.email]))
+ if not query:
+ return ""
+ return f"https://www.idealista.com/en/search/{quote_plus(query)}/"
+
+
+def _distance_km(lat1, lng1, lat2, lng2):
+ if None in (lat1, lng1, lat2, lng2):
+ return None
+ radius = 6371
+ phi1, phi2 = math.radians(float(lat1)), math.radians(float(lat2))
+ d_phi = math.radians(float(lat2) - float(lat1))
+ d_lambda = math.radians(float(lng2) - float(lng1))
+ a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
+ return radius * (2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)))
+
+
+def _decorate_distances(entries, user_lat, user_lng):
+ decorated = []
+ for entry in entries:
+ distance = _distance_km(user_lat, user_lng, entry.latitude, entry.longitude) if user_lat and user_lng else None
+ entry.distance_km = distance
+ decorated.append(entry)
+ return decorated
def home(request):
- """Render the landing screen with loader and environment details."""
- host_name = request.get_host().lower()
- agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
- now = timezone.now()
-
+ entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at")[:6])
context = {
- "project_name": "New Style",
- "agent_brand": agent_brand,
- "django_version": django_version(),
- "python_version": platform.python_version(),
- "current_time": now,
- "host_name": host_name,
- "project_description": os.getenv("PROJECT_DESCRIPTION", ""),
- "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
+ "page_title": "NearbyNest — mobile property pinboard",
+ "meta_description": "A mobile-first PWA for posting nearby property sightings by current location or photo.",
+ "recent_entries": entries,
+ "total_entries": PropertyEntry.objects.count(),
+ "photo_entries": PropertyEntry.objects.exclude(photo="").count(),
}
return render(request, "core/index.html", context)
+
+
+def onboarding(request):
+ context = {
+ "page_title": "Onboarding permissions — NearbyNest",
+ "meta_description": "Grant location, notification, and photo permissions for the mobile property pinboard.",
+ }
+ return render(request, "core/onboarding.html", context)
+
+
+def property_list(request):
+ sort = request.GET.get("sort", "recent")
+ user_lat = request.GET.get("lat")
+ user_lng = request.GET.get("lng")
+ entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at"))
+ entries = _decorate_distances(entries, user_lat, user_lng)
+ if sort == "distance" and user_lat and user_lng:
+ entries.sort(key=lambda entry: entry.distance_km if entry.distance_km is not None else float("inf"))
+ context = {
+ "page_title": "Public property list — NearbyNest",
+ "meta_description": "Browse public property posts by recency or distance from your current location.",
+ "entries": entries,
+ "sort": sort,
+ "user_lat": user_lat or "",
+ "user_lng": user_lng or "",
+ }
+ return render(request, "core/property_list.html", context)
+
+
+def property_detail(request, pk):
+ entry = get_object_or_404(PropertyEntry.objects.prefetch_related("suggestions", "flags"), pk=pk)
+ context = {
+ "page_title": f"Property #{entry.pk} — NearbyNest",
+ "meta_description": "Review a public property pin, extracted details, community suggestions, and flagging options.",
+ "entry": entry,
+ "suggestion_form": PropertySuggestionForm(),
+ "flag_form": PropertyFlagForm(),
+ }
+ return render(request, "core/property_detail.html", context)
+
+
+@transaction.atomic
+def add_location_property(request):
+ if request.method == "POST":
+ form = PropertyLocationForm(request.POST)
+ if form.is_valid():
+ entry = form.save(commit=False)
+ entry.source = PropertyEntry.Source.CURRENT_LOCATION
+ entry.idealista_url = build_idealista_url(entry)
+ entry.save()
+ messages.success(request, "Property pinned to the public list.")
+ return redirect(entry.get_absolute_url())
+ else:
+ form = PropertyLocationForm()
+ context = {
+ "page_title": "Add current-location property — NearbyNest",
+ "meta_description": "Add a property sighting from your current location, with optional contact details.",
+ "form": form,
+ }
+ return render(request, "core/property_form_location.html", context)
+
+
+@transaction.atomic
+def add_photo_property(request):
+ if request.method == "POST":
+ form = PropertyPhotoForm(request.POST, request.FILES)
+ if form.is_valid():
+ uploaded = form.cleaned_data["photo"]
+ processed, error = compress_image(uploaded, uploaded.name)
+ if error:
+ form.add_error("photo", error)
+ else:
+ uploaded.seek(0)
+ entry = form.save(commit=False)
+ entry.source = PropertyEntry.Source.PHOTO
+ entry.photo = processed["file"]
+ if processed["latitude"] is not None and not entry.latitude:
+ entry.latitude = processed["latitude"]
+ entry.longitude = processed["longitude"]
+ entry.has_gps_data = True
+ entry.extracted_text = ocr_text_best_effort(uploaded)
+ entry.idealista_url = build_idealista_url(entry)
+ entry.save()
+ messages.success(request, "Photo compressed under 256KB and added to the pinboard.")
+ return redirect(entry.get_absolute_url())
+ else:
+ form = PropertyPhotoForm()
+ context = {
+ "page_title": "Add property photo — NearbyNest",
+ "meta_description": "Upload a property photo; the MVP compresses it, checks EXIF GPS, and attempts local OCR.",
+ "form": form,
+ }
+ return render(request, "core/property_form_photo.html", context)
+
+
+@transaction.atomic
+def suggest_property_update(request, pk):
+ entry = get_object_or_404(PropertyEntry, pk=pk)
+ if request.method != "POST":
+ return redirect(entry.get_absolute_url())
+ form = PropertySuggestionForm(request.POST)
+ if form.is_valid():
+ suggestion = form.save(commit=False)
+ suggestion.property_entry = entry
+ suggestion.save()
+ messages.success(request, "Thanks — your suggested details were saved for review.")
+ else:
+ messages.error(request, "Please add at least one valid detail before submitting a suggestion.")
+ return redirect(entry.get_absolute_url())
+
+
+@transaction.atomic
+def flag_property(request, pk):
+ entry = get_object_or_404(PropertyEntry, pk=pk)
+ if request.method != "POST":
+ return redirect(entry.get_absolute_url())
+ form = PropertyFlagForm(request.POST)
+ if form.is_valid():
+ flag = form.save(commit=False)
+ flag.property_entry = entry
+ flag.save()
+ entry.flag_count = entry.flags.count()
+ entry.is_flagged = entry.flag_count >= 3
+ entry.save(update_fields=["flag_count", "is_flagged", "updated_at"])
+ messages.success(request, "Flag saved. Entries with repeated flags are hidden from the public list.")
+ else:
+ messages.error(request, "Add a short reason so reviewers know what to check.")
+ return redirect(entry.get_absolute_url())
diff --git a/requirements.txt b/requirements.txt
index e22994c..6121446 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,5 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
+
+Pillow==9.4.0
diff --git a/static/css/custom.css b/static/css/custom.css
index 925f6ed..4f41501 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -1,4 +1,63 @@
-/* Custom styles for the application */
-body {
- font-family: system-ui, -apple-system, sans-serif;
+/* NearbyNest custom brand system */
+:root {
+ --nest-ink: #14213d;
+ --nest-muted: #667085;
+ --nest-bg: #f7f3ea;
+ --nest-surface: #fffaf0;
+ --nest-primary: #0f766e;
+ --nest-primary-dark: #0b4f4a;
+ --nest-secondary: #ffb703;
+ --nest-accent: #ef476f;
+ --nest-mint: #d7fff1;
+ --nest-border: rgba(20, 33, 61, 0.12);
+ --nest-shadow: 0 24px 70px rgba(20, 33, 61, 0.14);
}
+* { box-sizing: border-box; }
+body { margin: 0; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--nest-ink); background: radial-gradient(circle at 8% 0%, rgba(255, 183, 3, 0.24), transparent 28rem), radial-gradient(circle at 92% 16%, rgba(15, 118, 110, 0.18), transparent 30rem), var(--nest-bg); min-height: 100vh; }
+h1, h2, h3, .brand-mark { font-family: "Plus Jakarta Sans", "Inter", sans-serif; }
+a { color: var(--nest-primary); text-decoration: none; } a:hover { color: var(--nest-primary-dark); }
+.site-header { background: rgba(247, 243, 234, 0.78); border-bottom: 1px solid var(--nest-border); backdrop-filter: blur(20px); }
+.navbar-brand, .nav-link { color: var(--nest-ink); font-weight: 700; } .nav-link:hover, .admin-link { color: var(--nest-primary); } .navbar-toggler { border: 0; }
+.brand-mark { display: inline-flex; align-items: center; gap: .55rem; font-weight: 800; letter-spacing: -.03em; }
+.brand-icon { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: 1rem; background: linear-gradient(135deg, var(--nest-primary), #20c997); color: white; box-shadow: 0 12px 30px rgba(15, 118, 110, .28); }
+.btn { font-weight: 800; border-radius: 999px; padding: .75rem 1.15rem; }
+.btn-nest { color: white; background: linear-gradient(135deg, var(--nest-primary), #14b8a6); border: 0; box-shadow: 0 16px 36px rgba(15, 118, 110, .28); }
+.btn-nest:hover { color: white; transform: translateY(-1px); box-shadow: 0 20px 44px rgba(15, 118, 110, .34); }
+.btn-ghost { color: var(--nest-ink); background: rgba(255,255,255,.62); border: 1px solid rgba(20,33,61,.14); backdrop-filter: blur(14px); }
+.btn-ghost:hover { background: white; border-color: rgba(15,118,110,.35); }
+.flash-stack { position: fixed; z-index: 1050; top: 5.25rem; left: 0; right: 0; max-width: 720px; }
+.hero-section { position: relative; overflow: hidden; padding: clamp(4rem, 8vw, 7.5rem) 0 4rem; }
+.hero-title { font-size: clamp(3rem, 8vw, 5.8rem); line-height: .92; letter-spacing: -.075em; margin-bottom: 1.2rem; }
+.hero-copy { max-width: 42rem; color: var(--nest-muted); font-size: clamp(1.05rem, 2vw, 1.28rem); }
+.eyebrow { text-transform: uppercase; letter-spacing: .16em; color: var(--nest-primary); font-size: .78rem; font-weight: 900; margin-bottom: .8rem; }
+.permission-strip { display: flex; flex-wrap: wrap; gap: .65rem; }
+.permission-strip span, .mini-tags span { display: inline-flex; align-items: center; border-radius: 999px; padding: .45rem .75rem; background: rgba(255,255,255,.68); border: 1px solid var(--nest-border); font-size: .83rem; font-weight: 800; }
+.orb { position: absolute; border-radius: 999px; filter: blur(2px); opacity: .85; pointer-events: none; } .orb-one { width: 8rem; height: 8rem; background: var(--nest-secondary); top: 8rem; right: 12%; } .orb-two { width: 5rem; height: 5rem; background: var(--nest-accent); bottom: 5rem; left: 8%; }
+.phone-frame { max-width: 23rem; border-radius: 2.5rem; padding: .9rem; background: #102a43; box-shadow: var(--nest-shadow); transform: rotate(2deg); }
+.phone-top { width: 5rem; height: .35rem; border-radius: 999px; background: rgba(255,255,255,.35); margin: 0 auto .8rem; }
+.map-card { position: relative; overflow: hidden; min-height: 34rem; border-radius: 2rem; background: linear-gradient(150deg, #d7fff1, #fffaf0 52%, #ffe4ad); }
+.map-grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(20,33,61,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(20,33,61,.08) 1px, transparent 1px); background-size: 42px 42px; transform: rotate(-12deg) scale(1.15); }
+.pin { position: absolute; width: 1.4rem; height: 1.4rem; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--nest-accent); box-shadow: 0 10px 22px rgba(239,71,111,.35); }
+.pin::after { content: ""; position: absolute; inset: .38rem; border-radius: 999px; background: white; } .pin-a { left: 25%; top: 23%; } .pin-b { right: 22%; top: 42%; background: var(--nest-primary); } .pin-c { left: 42%; bottom: 23%; background: var(--nest-secondary); }
+.glass-panel { position: absolute; left: 1.1rem; right: 1.1rem; bottom: 1.1rem; padding: 1.2rem; border-radius: 1.5rem; background: rgba(255,255,255,.78); border: 1px solid rgba(255,255,255,.8); backdrop-filter: blur(18px); box-shadow: 0 18px 42px rgba(20,33,61,.14); } .glass-panel h2 { margin: .8rem 0 .25rem; font-size: 1.35rem; }
+.section-pad { padding: 4rem 0; } .section-heading h2, .page-heading h1, .list-hero h1 { font-size: clamp(2rem, 4vw, 3.4rem); letter-spacing: -.055em; }
+.feature-card, .property-card, .form-card, .detail-panel, .activity-card, .sort-panel, .permission-wizard { background: rgba(255,250,240,.86); border: 1px solid var(--nest-border); border-radius: 2rem; box-shadow: 0 18px 54px rgba(20,33,61,.08); }
+.feature-card { padding: 1.5rem; } .feature-card h2 { font-size: 1.35rem; margin-top: 1rem; } .feature-card p, .text-muted, .card-meta { color: var(--nest-muted) !important; } .feature-icon, .step-icon { font-size: 2rem; }
+.property-card { position: relative; overflow: hidden; transition: transform .2s ease, box-shadow .2s ease; } .property-card:hover { transform: translateY(-4px); box-shadow: var(--nest-shadow); }
+.property-thumb { width: 100%; height: 13rem; object-fit: cover; background: var(--nest-mint); } .placeholder-thumb { display: grid; place-items: center; font-size: 4rem; color: var(--nest-primary); }
+.property-card-body { padding: 1.15rem; } .property-card h3 { font-size: 1.2rem; margin: .8rem 0 .25rem; } .listing-badge { background: #14213d; color: white; }
+.mini-tags { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .9rem; } .mini-tags span { padding: .28rem .55rem; font-size: .72rem; }
+.empty-state { text-align: center; padding: 3rem 1.5rem; border: 1px dashed rgba(20,33,61,.24); border-radius: 2rem; background: rgba(255,255,255,.5); }
+.site-footer { margin-top: 4rem; padding: 2rem 0; color: var(--nest-muted); border-top: 1px solid var(--nest-border); }
+.page-shell { padding: 3rem 0 1rem; } .narrow-container { max-width: 780px; } .page-heading { margin-bottom: 2rem; } .page-heading p { color: var(--nest-muted); }
+.permission-wizard { display: grid; gap: 1rem; padding: 1rem; } .permission-step { display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: center; padding: 1.2rem; border-radius: 1.4rem; background: rgba(255,255,255,.56); }
+.permission-step h2 { font-size: 1.25rem; margin: .3rem 0; } .status-text { color: var(--nest-muted); font-weight: 700; } .action-dock { display: flex; flex-wrap: wrap; justify-content: center; gap: .8rem; margin-top: 1.4rem; }
+.form-card { padding: clamp(1.2rem, 4vw, 2rem); } .form-card h1 { letter-spacing: -.055em; }
+.form-control, .form-select { border-radius: 1rem; border-color: rgba(20,33,61,.16); padding: .82rem 1rem; } .form-control:focus, .form-select:focus { border-color: var(--nest-primary); box-shadow: 0 0 0 .25rem rgba(15,118,110,.12); }
+.invalid-copy { color: #b42318; font-weight: 700; font-size: .9rem; margin-top: .35rem; }
+.list-hero { display: flex; justify-content: space-between; align-items: end; gap: 1.5rem; margin-bottom: 1.25rem; } .sort-panel { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; padding: 1rem; margin-bottom: 1rem; } .sort-panel > div { min-width: min(100%, 16rem); }
+.detail-panel { overflow: hidden; } .detail-photo { width: 100%; max-height: 28rem; object-fit: cover; } .detail-body { padding: clamp(1.3rem, 4vw, 2rem); } .detail-body h1 { margin-top: 1rem; letter-spacing: -.055em; }
+.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: .9rem; margin: 1.5rem 0; } .detail-grid div, .analysis-box { padding: 1rem; border-radius: 1.2rem; background: rgba(255,255,255,.62); border: 1px solid var(--nest-border); }
+.detail-grid dt { color: var(--nest-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .1em; } .detail-grid dd { margin: .2rem 0 0; font-weight: 800; word-break: break-word; }
+.analysis-box h2, .compact-card h2, .activity-card h2 { font-size: 1.2rem; } .side-stack { display: grid; gap: 1rem; } .compact-card { border-radius: 1.5rem; } .danger-soft { background: rgba(255, 245, 245, .88); } .activity-card { padding: 1.3rem; }
+@media (max-width: 767px) { .hero-section { padding-top: 3rem; } .phone-frame { transform: none; } .map-card { min-height: 27rem; } .permission-step, .list-hero { display: block; } .permission-step .btn { width: 100%; margin: .75rem 0 .35rem; } .detail-grid { grid-template-columns: 1fr; } }
diff --git a/static/js/pinboard.js b/static/js/pinboard.js
new file mode 100644
index 0000000..aa801da
--- /dev/null
+++ b/static/js/pinboard.js
@@ -0,0 +1,56 @@
+function setText(id, text) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = text;
+}
+
+function requestLocation(callback, statusId) {
+ if (!navigator.geolocation) {
+ setText(statusId, "Location is not supported by this browser.");
+ return;
+ }
+ setText(statusId, "Requesting location…");
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const lat = pos.coords.latitude.toFixed(6);
+ const lng = pos.coords.longitude.toFixed(6);
+ setText(statusId, `Captured ${lat}, ${lng}`);
+ callback(lat, lng);
+ },
+ () => setText(statusId, "Location permission was denied or unavailable."),
+ { enableHighAccuracy: true, timeout: 10000 }
+ );
+}
+
+document.addEventListener("click", (event) => {
+ const action = event.target?.dataset?.action;
+ if (action === "request-location") {
+ requestLocation(() => {}, "location-status");
+ }
+ if (action === "request-notifications") {
+ if (!window.Notification) {
+ setText("notification-status", "Notifications are not supported here.");
+ return;
+ }
+ Notification.requestPermission().then((permission) => {
+ setText("notification-status", `Notification permission: ${permission}`);
+ });
+ }
+ if (action === "fill-current-location") {
+ requestLocation((lat, lng) => {
+ const latInput = document.getElementById("id_latitude");
+ const lngInput = document.getElementById("id_longitude");
+ if (latInput) latInput.value = lat;
+ if (lngInput) lngInput.value = lng;
+ }, "form-location-status");
+ }
+ if (action === "use-location-for-list") {
+ requestLocation((lat, lng) => {
+ document.querySelector("[data-user-lat]").value = lat;
+ document.querySelector("[data-user-lng]").value = lng;
+ const sort = document.getElementById("sort");
+ if (sort) sort.value = "distance";
+ const form = document.querySelector("[data-distance-form]");
+ if (form) form.submit();
+ }, "list-location-status");
+ }
+});
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 108056f..4f41501 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -1,21 +1,63 @@
-
+/* NearbyNest custom brand system */
:root {
- --bg-color-start: #6a11cb;
- --bg-color-end: #2575fc;
- --text-color: #ffffff;
- --card-bg-color: rgba(255, 255, 255, 0.01);
- --card-border-color: rgba(255, 255, 255, 0.1);
-}
-body {
- margin: 0;
- font-family: 'Inter', sans-serif;
- background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
- color: var(--text-color);
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 100vh;
- text-align: center;
- overflow: hidden;
- position: relative;
+ --nest-ink: #14213d;
+ --nest-muted: #667085;
+ --nest-bg: #f7f3ea;
+ --nest-surface: #fffaf0;
+ --nest-primary: #0f766e;
+ --nest-primary-dark: #0b4f4a;
+ --nest-secondary: #ffb703;
+ --nest-accent: #ef476f;
+ --nest-mint: #d7fff1;
+ --nest-border: rgba(20, 33, 61, 0.12);
+ --nest-shadow: 0 24px 70px rgba(20, 33, 61, 0.14);
}
+* { box-sizing: border-box; }
+body { margin: 0; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--nest-ink); background: radial-gradient(circle at 8% 0%, rgba(255, 183, 3, 0.24), transparent 28rem), radial-gradient(circle at 92% 16%, rgba(15, 118, 110, 0.18), transparent 30rem), var(--nest-bg); min-height: 100vh; }
+h1, h2, h3, .brand-mark { font-family: "Plus Jakarta Sans", "Inter", sans-serif; }
+a { color: var(--nest-primary); text-decoration: none; } a:hover { color: var(--nest-primary-dark); }
+.site-header { background: rgba(247, 243, 234, 0.78); border-bottom: 1px solid var(--nest-border); backdrop-filter: blur(20px); }
+.navbar-brand, .nav-link { color: var(--nest-ink); font-weight: 700; } .nav-link:hover, .admin-link { color: var(--nest-primary); } .navbar-toggler { border: 0; }
+.brand-mark { display: inline-flex; align-items: center; gap: .55rem; font-weight: 800; letter-spacing: -.03em; }
+.brand-icon { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: 1rem; background: linear-gradient(135deg, var(--nest-primary), #20c997); color: white; box-shadow: 0 12px 30px rgba(15, 118, 110, .28); }
+.btn { font-weight: 800; border-radius: 999px; padding: .75rem 1.15rem; }
+.btn-nest { color: white; background: linear-gradient(135deg, var(--nest-primary), #14b8a6); border: 0; box-shadow: 0 16px 36px rgba(15, 118, 110, .28); }
+.btn-nest:hover { color: white; transform: translateY(-1px); box-shadow: 0 20px 44px rgba(15, 118, 110, .34); }
+.btn-ghost { color: var(--nest-ink); background: rgba(255,255,255,.62); border: 1px solid rgba(20,33,61,.14); backdrop-filter: blur(14px); }
+.btn-ghost:hover { background: white; border-color: rgba(15,118,110,.35); }
+.flash-stack { position: fixed; z-index: 1050; top: 5.25rem; left: 0; right: 0; max-width: 720px; }
+.hero-section { position: relative; overflow: hidden; padding: clamp(4rem, 8vw, 7.5rem) 0 4rem; }
+.hero-title { font-size: clamp(3rem, 8vw, 5.8rem); line-height: .92; letter-spacing: -.075em; margin-bottom: 1.2rem; }
+.hero-copy { max-width: 42rem; color: var(--nest-muted); font-size: clamp(1.05rem, 2vw, 1.28rem); }
+.eyebrow { text-transform: uppercase; letter-spacing: .16em; color: var(--nest-primary); font-size: .78rem; font-weight: 900; margin-bottom: .8rem; }
+.permission-strip { display: flex; flex-wrap: wrap; gap: .65rem; }
+.permission-strip span, .mini-tags span { display: inline-flex; align-items: center; border-radius: 999px; padding: .45rem .75rem; background: rgba(255,255,255,.68); border: 1px solid var(--nest-border); font-size: .83rem; font-weight: 800; }
+.orb { position: absolute; border-radius: 999px; filter: blur(2px); opacity: .85; pointer-events: none; } .orb-one { width: 8rem; height: 8rem; background: var(--nest-secondary); top: 8rem; right: 12%; } .orb-two { width: 5rem; height: 5rem; background: var(--nest-accent); bottom: 5rem; left: 8%; }
+.phone-frame { max-width: 23rem; border-radius: 2.5rem; padding: .9rem; background: #102a43; box-shadow: var(--nest-shadow); transform: rotate(2deg); }
+.phone-top { width: 5rem; height: .35rem; border-radius: 999px; background: rgba(255,255,255,.35); margin: 0 auto .8rem; }
+.map-card { position: relative; overflow: hidden; min-height: 34rem; border-radius: 2rem; background: linear-gradient(150deg, #d7fff1, #fffaf0 52%, #ffe4ad); }
+.map-grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(20,33,61,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(20,33,61,.08) 1px, transparent 1px); background-size: 42px 42px; transform: rotate(-12deg) scale(1.15); }
+.pin { position: absolute; width: 1.4rem; height: 1.4rem; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--nest-accent); box-shadow: 0 10px 22px rgba(239,71,111,.35); }
+.pin::after { content: ""; position: absolute; inset: .38rem; border-radius: 999px; background: white; } .pin-a { left: 25%; top: 23%; } .pin-b { right: 22%; top: 42%; background: var(--nest-primary); } .pin-c { left: 42%; bottom: 23%; background: var(--nest-secondary); }
+.glass-panel { position: absolute; left: 1.1rem; right: 1.1rem; bottom: 1.1rem; padding: 1.2rem; border-radius: 1.5rem; background: rgba(255,255,255,.78); border: 1px solid rgba(255,255,255,.8); backdrop-filter: blur(18px); box-shadow: 0 18px 42px rgba(20,33,61,.14); } .glass-panel h2 { margin: .8rem 0 .25rem; font-size: 1.35rem; }
+.section-pad { padding: 4rem 0; } .section-heading h2, .page-heading h1, .list-hero h1 { font-size: clamp(2rem, 4vw, 3.4rem); letter-spacing: -.055em; }
+.feature-card, .property-card, .form-card, .detail-panel, .activity-card, .sort-panel, .permission-wizard { background: rgba(255,250,240,.86); border: 1px solid var(--nest-border); border-radius: 2rem; box-shadow: 0 18px 54px rgba(20,33,61,.08); }
+.feature-card { padding: 1.5rem; } .feature-card h2 { font-size: 1.35rem; margin-top: 1rem; } .feature-card p, .text-muted, .card-meta { color: var(--nest-muted) !important; } .feature-icon, .step-icon { font-size: 2rem; }
+.property-card { position: relative; overflow: hidden; transition: transform .2s ease, box-shadow .2s ease; } .property-card:hover { transform: translateY(-4px); box-shadow: var(--nest-shadow); }
+.property-thumb { width: 100%; height: 13rem; object-fit: cover; background: var(--nest-mint); } .placeholder-thumb { display: grid; place-items: center; font-size: 4rem; color: var(--nest-primary); }
+.property-card-body { padding: 1.15rem; } .property-card h3 { font-size: 1.2rem; margin: .8rem 0 .25rem; } .listing-badge { background: #14213d; color: white; }
+.mini-tags { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .9rem; } .mini-tags span { padding: .28rem .55rem; font-size: .72rem; }
+.empty-state { text-align: center; padding: 3rem 1.5rem; border: 1px dashed rgba(20,33,61,.24); border-radius: 2rem; background: rgba(255,255,255,.5); }
+.site-footer { margin-top: 4rem; padding: 2rem 0; color: var(--nest-muted); border-top: 1px solid var(--nest-border); }
+.page-shell { padding: 3rem 0 1rem; } .narrow-container { max-width: 780px; } .page-heading { margin-bottom: 2rem; } .page-heading p { color: var(--nest-muted); }
+.permission-wizard { display: grid; gap: 1rem; padding: 1rem; } .permission-step { display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: center; padding: 1.2rem; border-radius: 1.4rem; background: rgba(255,255,255,.56); }
+.permission-step h2 { font-size: 1.25rem; margin: .3rem 0; } .status-text { color: var(--nest-muted); font-weight: 700; } .action-dock { display: flex; flex-wrap: wrap; justify-content: center; gap: .8rem; margin-top: 1.4rem; }
+.form-card { padding: clamp(1.2rem, 4vw, 2rem); } .form-card h1 { letter-spacing: -.055em; }
+.form-control, .form-select { border-radius: 1rem; border-color: rgba(20,33,61,.16); padding: .82rem 1rem; } .form-control:focus, .form-select:focus { border-color: var(--nest-primary); box-shadow: 0 0 0 .25rem rgba(15,118,110,.12); }
+.invalid-copy { color: #b42318; font-weight: 700; font-size: .9rem; margin-top: .35rem; }
+.list-hero { display: flex; justify-content: space-between; align-items: end; gap: 1.5rem; margin-bottom: 1.25rem; } .sort-panel { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; padding: 1rem; margin-bottom: 1rem; } .sort-panel > div { min-width: min(100%, 16rem); }
+.detail-panel { overflow: hidden; } .detail-photo { width: 100%; max-height: 28rem; object-fit: cover; } .detail-body { padding: clamp(1.3rem, 4vw, 2rem); } .detail-body h1 { margin-top: 1rem; letter-spacing: -.055em; }
+.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: .9rem; margin: 1.5rem 0; } .detail-grid div, .analysis-box { padding: 1rem; border-radius: 1.2rem; background: rgba(255,255,255,.62); border: 1px solid var(--nest-border); }
+.detail-grid dt { color: var(--nest-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .1em; } .detail-grid dd { margin: .2rem 0 0; font-weight: 800; word-break: break-word; }
+.analysis-box h2, .compact-card h2, .activity-card h2 { font-size: 1.2rem; } .side-stack { display: grid; gap: 1rem; } .compact-card { border-radius: 1.5rem; } .danger-soft { background: rgba(255, 245, 245, .88); } .activity-card { padding: 1.3rem; }
+@media (max-width: 767px) { .hero-section { padding-top: 3rem; } .phone-frame { transform: none; } .map-card { min-height: 27rem; } .permission-step, .list-hero { display: block; } .permission-step .btn { width: 100%; margin: .75rem 0 .35rem; } .detail-grid { grid-template-columns: 1fr; } }
diff --git a/staticfiles/js/pinboard.js b/staticfiles/js/pinboard.js
new file mode 100644
index 0000000..aa801da
--- /dev/null
+++ b/staticfiles/js/pinboard.js
@@ -0,0 +1,56 @@
+function setText(id, text) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = text;
+}
+
+function requestLocation(callback, statusId) {
+ if (!navigator.geolocation) {
+ setText(statusId, "Location is not supported by this browser.");
+ return;
+ }
+ setText(statusId, "Requesting location…");
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const lat = pos.coords.latitude.toFixed(6);
+ const lng = pos.coords.longitude.toFixed(6);
+ setText(statusId, `Captured ${lat}, ${lng}`);
+ callback(lat, lng);
+ },
+ () => setText(statusId, "Location permission was denied or unavailable."),
+ { enableHighAccuracy: true, timeout: 10000 }
+ );
+}
+
+document.addEventListener("click", (event) => {
+ const action = event.target?.dataset?.action;
+ if (action === "request-location") {
+ requestLocation(() => {}, "location-status");
+ }
+ if (action === "request-notifications") {
+ if (!window.Notification) {
+ setText("notification-status", "Notifications are not supported here.");
+ return;
+ }
+ Notification.requestPermission().then((permission) => {
+ setText("notification-status", `Notification permission: ${permission}`);
+ });
+ }
+ if (action === "fill-current-location") {
+ requestLocation((lat, lng) => {
+ const latInput = document.getElementById("id_latitude");
+ const lngInput = document.getElementById("id_longitude");
+ if (latInput) latInput.value = lat;
+ if (lngInput) lngInput.value = lng;
+ }, "form-location-status");
+ }
+ if (action === "use-location-for-list") {
+ requestLocation((lat, lng) => {
+ document.querySelector("[data-user-lat]").value = lat;
+ document.querySelector("[data-user-lng]").value = lng;
+ const sort = document.getElementById("sort");
+ if (sort) sort.value = "distance";
+ const form = document.querySelector("[data-distance-form]");
+ if (form) form.submit();
+ }, "list-location-status");
+ }
+});