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.

+ +
+ LocationNotificationsPhotos +
+
+
+
+
+
+
+
+
+
+
+ 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 %} +
+
- -{% 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.

+ + Not requested yet +
+
+
🔔

Notifications

Future-ready prompt for review alerts and nearby updates.

+ + 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 %} + Uploaded property photo for entry {{ entry.pk }} + {% 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 %} + Uploaded property photo for entry {{ entry.pk }} + {% 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 %} +
+
+
+

Create from current location

+

Add a property pin

+

Address or GPS is required. Contact details and sale/rental type help others recognize the listing.

+ + Location not captured yet. +
+ {% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + {{ form.latitude }}{{ form.longitude }} + {% for field in form.visible_fields %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ + Cancel +
+
+
+
+
+{% 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 %} +
+
+
+

Create from photo

+

Upload a property photo

+

The MVP stores only a resized JPEG under 256KB, checks EXIF GPS, and attempts local OCR if a system OCR binary is installed.

+
+ {% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + {% for field in form.visible_fields %} +
+ + {{ field }} + {% if field.name == 'photo' %}
Originals are discarded after compression; max upload 12MB.
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ + Cancel +
+
+
+
+
+{% 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"); + } +});