Compare commits

...

4 Commits

Author SHA1 Message Date
Flatlogic Bot
cb35610046 1.0.3 2026-06-04 18:40:22 +00:00
Flatlogic Bot
a0758d4015 1.0.2 2026-06-04 17:14:08 +00:00
Flatlogic Bot
f3690a8b42 1.0.1 2026-06-04 16:43:04 +00:00
Flatlogic Bot
09fd8f477a 1.0 2026-06-04 16:38:15 +00:00
39 changed files with 1442 additions and 194 deletions

View File

@ -20,21 +20,34 @@ load_dotenv(BASE_DIR.parent / ".env")
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
def _host_from_env(value):
return value.replace("https://", "").replace("http://", "").split("/")[0].strip()
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
"127.0.0.1", host for host in [
"localhost", "127.0.0.1",
os.getenv("HOST_FQDN", ""), "localhost",
".appwizzy.dev",
".dev.flatlogic.app",
_host_from_env(os.getenv("HOST_FQDN", "")),
_host_from_env(os.getenv("FULL_DOMAIN", "")),
] if host
] ]
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
origin for origin in [ origin for origin in [
"https://*.appwizzy.dev",
"http://*.appwizzy.dev",
"https://*.dev.flatlogic.app",
os.getenv("HOST_FQDN", ""), os.getenv("HOST_FQDN", ""),
os.getenv("FULL_DOMAIN", ""),
os.getenv("CSRF_TRUSTED_ORIGIN", "") os.getenv("CSRF_TRUSTED_ORIGIN", "")
] if origin ] if origin
] ]
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
f"https://{host}" if not host.startswith(("http://", "https://")) else host f"https://{origin}" if not origin.startswith(("http://", "https://")) else origin
for host in CSRF_TRUSTED_ORIGINS for origin in CSRF_TRUSTED_ORIGINS
] ]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy. # Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
@ -150,9 +163,12 @@ STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static', path for path in [
BASE_DIR / 'assets', BASE_DIR / 'static',
BASE_DIR / 'node_modules', BASE_DIR / 'assets',
BASE_DIR / 'node_modules',
]
if path.exists()
] ]
# Email # Email
@ -180,3 +196,8 @@ if EMAIL_USE_SSL:
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Media uploads
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

View File

@ -27,3 +27,4 @@ urlpatterns = [
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,36 @@
from django.contrib import admin 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", "idealista_url", "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",)

164
core/forms.py Normal file
View File

@ -0,0 +1,164 @@
from urllib.parse import urlparse
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from .models import PropertyEntry, PropertyFlag, PropertySuggestion
IDEALISTA_DOMAINS = ("idealista.com", "idealista.pt", "idealista.it")
def idealista_url_field(*, required=False, label="Idealista link"):
return forms.CharField(
required=required,
label=label,
help_text=(
"Optional. Paste the exact Idealista listing URL if you already found it. "
"If left empty, well create a best-effort Idealista search link."
),
widget=forms.URLInput(
attrs={
"placeholder": "https://www.idealista.com/inmueble/123456/",
"autocomplete": "url",
"inputmode": "url",
}
),
)
def clean_idealista_url_value(value, *, required=False):
value = (value or "").strip()
if not value:
if required:
raise ValidationError("Paste the exact Idealista listing URL.")
return ""
if "://" not in value:
value = f"https://{value}"
validator = URLValidator(schemes=["http", "https"])
try:
validator(value)
except ValidationError as exc:
raise ValidationError(
"Paste a valid Idealista URL, for example https://www.idealista.com/inmueble/123456/."
) from exc
host = (urlparse(value).hostname or "").lower()
is_idealista = any(host == domain or host.endswith(f".{domain}") for domain in IDEALISTA_DOMAINS)
if not is_idealista:
raise ValidationError("Use a link from idealista.com, idealista.pt, or idealista.it.")
return value
class IdealistaUrlCleanMixin:
def clean_idealista_url(self):
field = self.fields["idealista_url"]
return clean_idealista_url_value(self.cleaned_data.get("idealista_url"), required=field.required)
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(IdealistaUrlCleanMixin, BootstrapFormMixin, forms.ModelForm):
idealista_url = idealista_url_field()
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", "idealista_url"]
widgets = {
"address": forms.TextInput(attrs={"placeholder": "Type or paste the full address", "autocomplete": "street-address", "data-manual-address": "true"}),
"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 address:
cleaned["latitude"] = None
cleaned["longitude"] = None
return cleaned
if latitude is None or longitude is None:
raise forms.ValidationError("Type or paste an address, or allow location, so this property can be placed on the pinboard.")
return cleaned
class PropertyPhotoForm(IdealistaUrlCleanMixin, BootstrapFormMixin, forms.ModelForm):
idealista_url = idealista_url_field()
class Meta:
model = PropertyEntry
fields = ["photo", "address", "phone", "email", "listing_type", "idealista_url"]
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..."}),
}
class PropertyIdealistaLinkForm(IdealistaUrlCleanMixin, BootstrapFormMixin, forms.ModelForm):
idealista_url = idealista_url_field(required=True, label="Exact Idealista listing URL")
class Meta:
model = PropertyEntry
fields = ["idealista_url"]

104
core/image_tools.py Normal file
View File

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

View File

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

View File

@ -1,3 +1,81 @@
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models 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
@property
def idealista_is_search(self):
return "/search/" in (self.idealista_url or "")
@property
def idealista_link_label(self):
return "Open Idealista search" if self.idealista_is_search else "Open Idealista listing"
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}"

View File

@ -1,11 +1,13 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ page_title|default:"NearbyNest" }}{% endblock %}</title>
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Mobile-first public property pinboard for nearby sale and rental sightings.' }}{% endblock %}">
{% if project_description %} {% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}"> <meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}"> <meta property="twitter:description" content="{{ project_description }}">
{% endif %} {% endif %}
@ -13,13 +15,57 @@
<meta property="og:image" content="{{ project_image_url }}"> <meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}"> <meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} {% endif %}
{% load static %} <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<header class="site-header sticky-top">
<nav class="navbar navbar-expand-lg">
<div class="container py-2">
<a class="navbar-brand brand-mark" href="{% url 'home' %}" aria-label="NearbyNest home">
<span class="brand-icon"></span><span>NearbyNest</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="{% url 'property_list' %}">Browse</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'onboarding' %}">Onboarding</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'add_photo_property' %}">Upload photo</a></li>
<li class="nav-item"><a class="btn btn-nest btn-sm" href="{% url 'add_location_property' %}">Pin property</a></li>
<li class="nav-item"><a class="nav-link admin-link" href="/admin/">Admin</a></li>
</ul>
</div>
</div>
</nav>
</header>
{% if messages %}
<div class="container flash-stack" aria-live="polite">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm mb-2">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
<footer class="site-footer">
<div class="container d-flex flex-column flex-md-row justify-content-between gap-3">
<span>NearbyNest MVP · Public property sightings</span>
<span>Built for PWA now, mobile app later</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{% static 'js/pinboard.js' %}?v={{ deployment_timestamp }}" defer></script>
{% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@ -1,145 +1,101 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
: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);
}
* {
box-sizing: border-box;
}
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;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<main> <main>
<div class="card"> <section class="hero-section">
<h1>Analyzing your requirements and generating your app…</h1> <div class="orb orb-one"></div>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="orb orb-two"></div>
<span class="sr-only">Loading…</span> <div class="container">
<div class="row align-items-center g-5">
<div class="col-lg-7">
<p class="eyebrow">Mobile-first PWA · property pinboard</p>
<h1 class="hero-title">Spot a sale or rental nearby. Pin it in seconds.</h1>
<p class="hero-copy">NearbyNest lets anyone add a public property sighting from their current location or an uploaded photo, then browse fresh entries by distance or recency.</p>
<div class="d-flex flex-column flex-sm-row gap-3 mt-4">
<a class="btn btn-nest btn-lg" href="{% url 'onboarding' %}">Start onboarding</a>
<a class="btn btn-ghost btn-lg" href="{% url 'property_list' %}">Browse properties</a>
</div>
<div class="permission-strip mt-4" aria-label="Onboarding permissions">
<span>Location</span><span>Notifications</span><span>Photos</span>
</div>
</div>
<div class="col-lg-5">
<div class="phone-frame mx-lg-auto">
<div class="phone-top"></div>
<div class="map-card">
<div class="map-grid"></div>
<div class="pin pin-a"></div>
<div class="pin pin-b"></div>
<div class="pin pin-c"></div>
<div class="glass-panel">
<span class="badge rounded-pill text-bg-light">Live MVP</span>
<h2>Public nearby list</h2>
<p>{{ total_entries }} entries · {{ photo_entries }} photo uploads</p>
<a href="{% url 'add_location_property' %}">Add by location/address →</a>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p> </section>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime"> <section class="section-pad">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code> <div class="container">
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code> <div class="row g-4">
</p> <div class="col-md-4">
</div> <article class="feature-card h-100">
<span class="feature-icon">📍</span>
<h2>Pin by location</h2>
<p>Grant browser location permission, or type/paste an address if permission is unavailable, then submit optional contact details.</p>
<a href="{% url 'add_location_property' %}">Pin a property</a>
</article>
</div>
<div class="col-md-4">
<article class="feature-card h-100">
<span class="feature-icon">📷</span>
<h2>Upload photo</h2>
<p>Save only a compressed small image, check EXIF GPS, and attempt local OCR when a system OCR tool exists.</p>
<a href="{% url 'add_photo_property' %}">Upload photo</a>
</article>
</div>
<div class="col-md-4">
<article class="feature-card h-100">
<span class="feature-icon">🧭</span>
<h2>Browse & improve</h2>
<p>Sort the public list by recency or distance, flag questionable entries, or suggest missing details.</p>
<a href="{% url 'property_list' %}">View public list</a>
</article>
</div>
</div>
</div>
</section>
<section class="section-pad pt-0">
<div class="container">
<div class="section-heading d-flex flex-column flex-md-row justify-content-between gap-3 align-items-md-end">
<div>
<p class="eyebrow">Latest sightings</p>
<h2>Fresh public property pins</h2>
</div>
<a class="btn btn-outline-dark rounded-pill" href="{% url 'property_list' %}">See all</a>
</div>
{% if recent_entries %}
<div class="row g-4 mt-2">
{% for entry in recent_entries %}
<div class="col-md-6 col-xl-4">
{% include "core/partials/property_card.html" with entry=entry %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-4">
<h3>No properties yet</h3>
<p>Be the first to add a current-location pin or photo upload.</p>
<a class="btn btn-nest" href="{% url 'add_location_property' %}">Create first pin</a>
</div>
{% endif %}
</div>
</section>
</main> </main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<main class="page-shell">
<div class="container narrow-container">
<div class="page-heading text-center">
<p class="eyebrow">Permission onboarding</p>
<h1>Prepare your device for fast property pins.</h1>
<p>These browser prompts mimic the future mobile app permissions: location, notifications, and photo access where supported.</p>
</div>
<div class="permission-wizard">
<div class="permission-step">
<div><span class="step-icon">📍</span><h2>Location</h2><p>Used to add a property at your current position and sort entries by distance.</p></div>
<button class="btn btn-nest" data-action="request-location">Grant location</button>
<small id="location-status" class="status-text">Not requested yet</small>
</div>
<div class="permission-step">
<div><span class="step-icon">🔔</span><h2>Notifications</h2><p>Future-ready prompt for review alerts and nearby updates.</p></div>
<button class="btn btn-nest" data-action="request-notifications">Grant notifications</button>
<small id="notification-status" class="status-text">Not requested yet</small>
</div>
<div class="permission-step">
<div><span class="step-icon">🖼️</span><h2>Photos</h2><p>Web browsers ask when you choose a file; mobile apps can request photo library permission directly.</p></div>
<a class="btn btn-ghost" href="{% url 'add_photo_property' %}">Choose a photo</a>
<small class="status-text">Requested by the file picker on supported platforms</small>
</div>
</div>
<div class="action-dock">
<a class="btn btn-nest btn-lg" href="{% url 'add_location_property' %}">Add current-location property</a>
<a class="btn btn-outline-dark btn-lg rounded-pill" href="{% url 'property_list' %}">Skip to public list</a>
</div>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,24 @@
<article class="property-card h-100">
{% if entry.photo %}
<img class="property-thumb" src="{{ entry.photo.url }}" alt="Uploaded property photo for entry {{ entry.pk }}">
{% else %}
<div class="property-thumb placeholder-thumb" aria-hidden="true"></div>
{% endif %}
<div class="property-card-body">
<div class="d-flex justify-content-between gap-2 align-items-start">
<span class="badge listing-badge">{{ entry.get_listing_type_display }}</span>
<small class="text-muted">{{ entry.created_at|timesince }} ago</small>
</div>
<h3>{{ entry.address|default:"Location-only property" }}</h3>
<p class="card-meta">
{{ 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 %}
</p>
<div class="mini-tags">
{% if entry.phone %}<span>Phone</span>{% endif %}
{% if entry.email %}<span>Email</span>{% endif %}
{% if entry.idealista_url %}<span>{% if entry.idealista_is_search %}Idealista search{% else %}Idealista listing{% endif %}</span>{% endif %}
{% if not entry.phone and not entry.email %}<span>Needs details</span>{% endif %}
</div>
<a class="stretched-link" href="{{ entry.get_absolute_url }}" aria-label="Open property {{ entry.pk }}"></a>
</div>
</article>

View File

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block content %}
<main class="page-shell">
<div class="container">
<div class="row g-4">
<div class="col-lg-7">
<article class="detail-panel">
{% if entry.photo %}
<img class="detail-photo" src="{{ entry.photo.url }}" alt="Uploaded property photo for entry {{ entry.pk }}">
{% endif %}
<div class="detail-body">
<span class="badge listing-badge">{{ entry.get_listing_type_display }}</span>
<h1>{{ entry.address|default:"Location-only property" }}</h1>
<p class="text-muted">Added {{ entry.created_at|date:"M j, Y H:i" }} · {{ entry.get_source_display }}</p>
<dl class="detail-grid">
<div><dt>Phone</dt><dd>{{ entry.phone|default:"Missing" }}</dd></div>
<div><dt>Email</dt><dd>{{ entry.email|default:"Missing" }}</dd></div>
<div><dt>GPS</dt><dd>{% if entry.has_location %}{{ entry.latitude }}, {{ entry.longitude }}{% else %}Missing{% endif %}</dd></div>
<div><dt>Photo GPS</dt><dd>{{ entry.has_gps_data|yesno:"Detected,Not detected" }}</dd></div>
</dl>
{% if entry.extracted_text %}
<div class="analysis-box"><h2>Text spotted in photo</h2><p>{{ entry.extracted_text }}</p></div>
{% endif %}
{% if entry.idealista_url %}
<a class="btn btn-ghost" href="{{ entry.idealista_url }}" target="_blank" rel="noopener">{{ entry.idealista_link_label }}</a>
{% endif %}
</div>
</article>
</div>
<div class="col-lg-5">
<aside class="side-stack">
<section class="form-card compact-card">
<h2>Suggest missing details</h2>
<form method="post" action="{% url 'suggest_property_update' entry.pk %}">
{% csrf_token %}
{% for field in suggestion_form.visible_fields %}
<div class="mb-3"><label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>{{ field }}</div>
{% endfor %}
<button class="btn btn-nest w-100" type="submit">Save suggestion</button>
</form>
</section>
<section class="form-card compact-card">
<h2>{% if entry.idealista_url and not entry.idealista_is_search %}Update Idealista link{% else %}Add exact Idealista link{% endif %}</h2>
<p class="text-muted">{% if entry.idealista_url and entry.idealista_is_search %}We only have an automatic search link right now. Paste the exact listing URL if you find it.{% elif entry.idealista_url %}This listing already has an Idealista URL. You can replace it if needed.{% else %}No Idealista link was found automatically. Paste the exact listing URL if you know it.{% endif %}</p>
<form method="post" action="{% url 'update_idealista_link' entry.pk %}" novalidate>
{% csrf_token %}
<div class="mb-3">
<label class="form-label" for="{{ idealista_form.idealista_url.id_for_label }}">{{ idealista_form.idealista_url.label }}</label>
{{ idealista_form.idealista_url }}
<div class="form-text">Only Idealista links are accepted. You can paste without https://.</div>
</div>
<button class="btn btn-nest w-100" type="submit">Save Idealista link</button>
</form>
</section>
<section class="form-card compact-card danger-soft">
<h2>Flag for removal</h2>
<p class="text-muted">Repeated flags hide an entry until a future admin review flow is added.</p>
<form method="post" action="{% url 'flag_property' entry.pk %}">
{% csrf_token %}
{% for field in flag_form.visible_fields %}
<div class="mb-3"><label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>{{ field }}</div>
{% endfor %}
<button class="btn btn-outline-danger w-100 rounded-pill" type="submit">Flag entry</button>
</form>
</section>
<section class="activity-card">
<h2>Community activity</h2>
<p>{{ entry.suggestions.count }} suggestions · {{ entry.flag_count }} flags</p>
<a href="{% url 'property_list' %}">Back to public list →</a>
</section>
</aside>
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
<main class="page-shell">
<div class="container narrow-container">
<div class="form-card">
<p class="eyebrow">Create from location or address</p>
<h1>Add a property pin</h1>
<p class="text-muted">Use your current GPS position when available, or type/paste the address manually if browser location is blocked, denied, or unsupported. If you paste an address, NearbyNest uses that address for the listing instead of the browsers current location. If you already have the exact Idealista listing, paste it before publishing.</p>
<div class="location-choice-panel mb-3" aria-label="Choose how to add location information">
<button class="btn btn-ghost" type="button" data-action="fill-current-location">Use my current location</button>
<button class="btn btn-outline-dark rounded-pill" type="button" data-action="manual-address">Type or paste address</button>
</div>
<small id="form-location-status" class="status-text d-block mb-3">Choose current location or enter the address below.</small>
<div id="manual-address-tip" class="manual-address-tip mb-3" data-manual-address-tip hidden>
<span>Manual fallback</span>
<strong>Location permission is optional.</strong>
<p>Paste the full street address into the Address field below. GPS coordinates can stay empty when an address is provided.</p>
</div>
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
{{ form.latitude }}{{ form.longitude }}
{% for field in form.visible_fields %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.name == "address" %}<div class="form-text">Required if you do not grant current-location access.</div>{% endif %}
{% if field.name == "idealista_url" %}<div class="form-text">Optional: manual links are saved first. If empty, NearbyNest creates a best-effort Idealista search link.</div>{% endif %}
{% if field.help_text and field.name != "idealista_url" %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
{% for error in field.errors %}<div class="invalid-copy">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
<div class="d-grid gap-2 d-sm-flex">
<button class="btn btn-nest btn-lg" type="submit">Publish pin</button>
<a class="btn btn-outline-dark btn-lg rounded-pill" href="{% url 'property_list' %}">Cancel</a>
</div>
</form>
</div>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<main class="page-shell">
<div class="container narrow-container">
<div class="form-card">
<p class="eyebrow">Create from photo</p>
<h1>Upload a property photo</h1>
<p class="text-muted">The MVP stores only a resized JPEG under 256KB, checks EXIF GPS, and attempts local OCR. You can also paste the exact Idealista listing link before we create any search fallback.</p>
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
{% for field in form.visible_fields %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.name == 'photo' %}<div class="form-text">Originals are discarded after compression; max upload 12MB.</div>{% endif %}
{% if field.name == 'idealista_url' %}<div class="form-text">Optional: paste the exact Idealista listing URL. If empty, NearbyNest creates a best-effort search link.</div>{% endif %}
{% for error in field.errors %}<div class="invalid-copy">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
<div class="d-grid gap-2 d-sm-flex">
<button class="btn btn-nest btn-lg" type="submit">Analyze & publish</button>
<a class="btn btn-outline-dark btn-lg rounded-pill" href="{% url 'property_list' %}">Cancel</a>
</div>
</form>
</div>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<main class="page-shell">
<div class="container">
<div class="list-hero">
<div>
<p class="eyebrow">Public list</p>
<h1>Nearby property sightings</h1>
<p>Sort by newest posts or allow location to estimate the closest entries.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-nest" href="{% url 'add_location_property' %}">Pin location</a>
<a class="btn btn-ghost" href="{% url 'add_photo_property' %}">Upload photo</a>
</div>
</div>
<form class="sort-panel" method="get" data-distance-form>
<input type="hidden" name="lat" value="{{ user_lat }}" data-user-lat>
<input type="hidden" name="lng" value="{{ user_lng }}" data-user-lng>
<div>
<label class="form-label" for="sort">Sort entries</label>
<select class="form-select" id="sort" name="sort">
<option value="recent" {% if sort == 'recent' %}selected{% endif %}>Most recent</option>
<option value="distance" {% if sort == 'distance' %}selected{% endif %}>Distance from me</option>
</select>
</div>
<button class="btn btn-outline-dark rounded-pill" type="button" data-action="use-location-for-list">Use my location</button>
<button class="btn btn-nest" type="submit">Apply</button>
<small id="list-location-status" class="status-text"></small>
</form>
{% if entries %}
<div class="row g-4 mt-2">
{% for entry in entries %}
<div class="col-md-6 col-xl-4">
{% include "core/partials/property_card.html" with entry=entry %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-4">
<h2>No visible properties yet</h2>
<p>Add the first public pin from your location or upload a compressed property photo.</p>
<a class="btn btn-nest" href="{% url 'add_location_property' %}">Create first entry</a>
</div>
{% endif %}
</div>
</main>
{% endblock %}

View File

@ -1,3 +1,56 @@
from django.test import TestCase from django.test import TestCase
# Create your tests here. from .forms import PropertyLocationForm
class PropertyLocationFormTests(TestCase):
def test_address_overrides_browser_location(self):
form = PropertyLocationForm(
data={
"address": "123 Main St, Madrid",
"latitude": "40.416800",
"longitude": "-3.703800",
"phone": "",
"email": "",
"listing_type": "unknown",
"idealista_url": "",
}
)
self.assertTrue(form.is_valid(), form.errors)
self.assertIsNone(form.cleaned_data["latitude"])
self.assertIsNone(form.cleaned_data["longitude"])
self.assertEqual(form.cleaned_data["address"], "123 Main St, Madrid")
def test_browser_location_is_kept_when_no_address_is_entered(self):
form = PropertyLocationForm(
data={
"address": "",
"latitude": "40.416800",
"longitude": "-3.703800",
"phone": "",
"email": "",
"listing_type": "unknown",
"idealista_url": "",
}
)
self.assertTrue(form.is_valid(), form.errors)
self.assertIsNotNone(form.cleaned_data["latitude"])
self.assertIsNotNone(form.cleaned_data["longitude"])
def test_address_or_location_is_required(self):
form = PropertyLocationForm(
data={
"address": "",
"latitude": "",
"longitude": "",
"phone": "",
"email": "",
"listing_type": "unknown",
"idealista_url": "",
}
)
self.assertFalse(form.is_valid())
self.assertIn("__all__", form.errors)

View File

@ -1,7 +1,25 @@
from django.urls import path 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,
update_idealista_link,
)
urlpatterns = [ urlpatterns = [
path("", home, name="home"), 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/<int:pk>/", property_detail, name="property_detail"),
path("properties/<int:pk>/suggest/", suggest_property_update, name="suggest_property_update"),
path("properties/<int:pk>/idealista/", update_idealista_link, name="update_idealista_link"),
path("properties/<int:pk>/flag/", flag_property, name="flag_property"),
] ]

View File

@ -1,25 +1,209 @@
import os import math
import platform from urllib.parse import quote_plus
from django import get_version as django_version from django.contrib import messages
from django.shortcuts import render from django.db import transaction
from django.utils import timezone from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from .forms import (
PropertyFlagForm,
PropertyIdealistaLinkForm,
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 apply_idealista_fallback(entry):
if not entry.idealista_url:
entry.idealista_url = build_idealista_url(entry)
return entry
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): def home(request):
"""Render the landing screen with loader and environment details.""" entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at")[:6])
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
context = { context = {
"project_name": "New Style", "page_title": "NearbyNest — mobile property pinboard",
"agent_brand": agent_brand, "meta_description": "A mobile-first PWA for posting nearby property sightings by current location or photo.",
"django_version": django_version(), "recent_entries": entries,
"python_version": platform.python_version(), "total_entries": PropertyEntry.objects.count(),
"current_time": now, "photo_entries": PropertyEntry.objects.exclude(photo="").count(),
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, "core/index.html", context) 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(),
"idealista_form": PropertyIdealistaLinkForm(
initial={"idealista_url": entry.idealista_url} if entry.idealista_url and not entry.idealista_is_search else None
),
}
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
apply_idealista_fallback(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 property by location or address — NearbyNest",
"meta_description": "Add a property sighting from current GPS location or a typed/pasted address, 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)
apply_idealista_fallback(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 update_idealista_link(request, pk):
entry = get_object_or_404(PropertyEntry, pk=pk)
if request.method != "POST":
return redirect(entry.get_absolute_url())
form = PropertyIdealistaLinkForm(request.POST, instance=entry)
if form.is_valid():
form.save()
messages.success(request, "Idealista listing link saved.")
else:
message = form.errors.get("idealista_url", ["Paste a valid Idealista listing link before saving."])[0]
messages.error(request, message)
return redirect(entry.get_absolute_url())
@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())

View File

@ -1,3 +1,5 @@
Django==5.2.7 Django==5.2.7
mysqlclient==2.2.7 mysqlclient==2.2.7
python-dotenv==1.1.1 python-dotenv==1.1.1
Pillow==9.4.0

View File

@ -1,4 +1,64 @@
/* Custom styles for the application */ /* NearbyNest custom brand system */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --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; }
.location-choice-panel { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; } .manual-address-tip { padding: 1rem; border-radius: 1.25rem; background: rgba(215,255,241,.76); border: 1px solid rgba(15,118,110,.2); } .manual-address-tip span { display: inline-flex; margin-bottom: .35rem; color: var(--nest-primary); text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; font-weight: 900; } .manual-address-tip strong { display: block; } .manual-address-tip p { margin: .25rem 0 0; color: var(--nest-muted); }
.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; } }

80
static/js/pinboard.js Normal file
View File

@ -0,0 +1,80 @@
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function showManualAddressTip(message) {
const tip = document.querySelector("[data-manual-address-tip]");
const addressInput = document.querySelector("[data-manual-address]") || document.getElementById("id_address");
if (tip) {
tip.hidden = false;
}
if (message) {
setText("form-location-status", message);
}
if (addressInput) {
addressInput.focus({ preventScroll: true });
addressInput.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
function requestLocation(callback, statusId, unavailableCallback) {
if (!navigator.geolocation) {
setText(statusId, "Location is not supported by this browser. You can type or paste the address instead.");
if (unavailableCallback) unavailableCallback();
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. Type or paste the address instead.");
if (unavailableCallback) unavailableCallback();
},
{ 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 === "manual-address") {
showManualAddressTip("Manual address mode: paste or type the property address below.");
}
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", () => {
showManualAddressTip();
});
}
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");
}
});

View File

@ -1,21 +1,64 @@
/* NearbyNest custom brand system */
:root { :root {
--bg-color-start: #6a11cb; --nest-ink: #14213d;
--bg-color-end: #2575fc; --nest-muted: #667085;
--text-color: #ffffff; --nest-bg: #f7f3ea;
--card-bg-color: rgba(255, 255, 255, 0.01); --nest-surface: #fffaf0;
--card-border-color: rgba(255, 255, 255, 0.1); --nest-primary: #0f766e;
} --nest-primary-dark: #0b4f4a;
body { --nest-secondary: #ffb703;
margin: 0; --nest-accent: #ef476f;
font-family: 'Inter', sans-serif; --nest-mint: #d7fff1;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); --nest-border: rgba(20, 33, 61, 0.12);
color: var(--text-color); --nest-shadow: 0 24px 70px rgba(20, 33, 61, 0.14);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
} }
* { 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; }
.location-choice-panel { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; } .manual-address-tip { padding: 1rem; border-radius: 1.25rem; background: rgba(215,255,241,.76); border: 1px solid rgba(15,118,110,.2); } .manual-address-tip span { display: inline-flex; margin-bottom: .35rem; color: var(--nest-primary); text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; font-weight: 900; } .manual-address-tip strong { display: block; } .manual-address-tip p { margin: .25rem 0 0; color: var(--nest-muted); }
.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; } }

View File

@ -0,0 +1,80 @@
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function showManualAddressTip(message) {
const tip = document.querySelector("[data-manual-address-tip]");
const addressInput = document.querySelector("[data-manual-address]") || document.getElementById("id_address");
if (tip) {
tip.hidden = false;
}
if (message) {
setText("form-location-status", message);
}
if (addressInput) {
addressInput.focus({ preventScroll: true });
addressInput.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
function requestLocation(callback, statusId, unavailableCallback) {
if (!navigator.geolocation) {
setText(statusId, "Location is not supported by this browser. You can type or paste the address instead.");
if (unavailableCallback) unavailableCallback();
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. Type or paste the address instead.");
if (unavailableCallback) unavailableCallback();
},
{ 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 === "manual-address") {
showManualAddressTip("Manual address mode: paste or type the property address below.");
}
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", () => {
showManualAddressTip();
});
}
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");
}
});