Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb35610046 | ||
|
|
a0758d4015 | ||
|
|
f3690a8b42 | ||
|
|
09fd8f477a |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -20,21 +20,34 @@ load_dotenv(BASE_DIR.parent / ".env")
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||
|
||||
def _host_from_env(value):
|
||||
return value.replace("https://", "").replace("http://", "").split("/")[0].strip()
|
||||
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
host for host in [
|
||||
"127.0.0.1",
|
||||
"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 = [
|
||||
origin for origin in [
|
||||
"https://*.appwizzy.dev",
|
||||
"http://*.appwizzy.dev",
|
||||
"https://*.dev.flatlogic.app",
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
os.getenv("FULL_DOMAIN", ""),
|
||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
||||
] if origin
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
f"https://{host}" if not host.startswith(("http://", "https://")) else host
|
||||
for host in CSRF_TRUSTED_ORIGINS
|
||||
f"https://{origin}" if not origin.startswith(("http://", "https://")) else origin
|
||||
for origin in CSRF_TRUSTED_ORIGINS
|
||||
]
|
||||
|
||||
# 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'
|
||||
|
||||
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 +196,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"
|
||||
|
||||
@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/image_tools.cpython-311.pyc
Normal file
BIN
core/__pycache__/image_tools.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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", "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
164
core/forms.py
Normal 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, we’ll 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
104
core/image_tools.py
Normal 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 ""
|
||||
68
core/migrations/0001_initial.py
Normal file
68
core/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,3 +1,81 @@
|
||||
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
|
||||
|
||||
@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}"
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<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 %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
@ -13,13 +15,57 @@
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% 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 }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<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 %}
|
||||
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
@ -1,145 +1,101 @@
|
||||
{% 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 %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="hero-section">
|
||||
<div class="orb orb-one"></div>
|
||||
<div class="orb orb-two"></div>
|
||||
<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>
|
||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||
<p class="runtime">
|
||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-pad">
|
||||
<div class="container">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<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>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
34
core/templates/core/onboarding.html
Normal file
34
core/templates/core/onboarding.html
Normal 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 %}
|
||||
24
core/templates/core/partials/property_card.html
Normal file
24
core/templates/core/partials/property_card.html
Normal 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>
|
||||
77
core/templates/core/property_detail.html
Normal file
77
core/templates/core/property_detail.html
Normal 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 %}
|
||||
42
core/templates/core/property_form_location.html
Normal file
42
core/templates/core/property_form_location.html
Normal 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 browser’s 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 %}
|
||||
30
core/templates/core/property_form_photo.html
Normal file
30
core/templates/core/property_form_photo.html
Normal 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 %}
|
||||
50
core/templates/core/property_list.html
Normal file
50
core/templates/core/property_list.html
Normal 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 %}
|
||||
@ -1,3 +1,56 @@
|
||||
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)
|
||||
|
||||
20
core/urls.py
20
core/urls.py
@ -1,7 +1,25 @@
|
||||
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 = [
|
||||
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"),
|
||||
]
|
||||
|
||||
220
core/views.py
220
core/views.py
@ -1,25 +1,209 @@
|
||||
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,
|
||||
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):
|
||||
"""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(),
|
||||
"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())
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
Django==5.2.7
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
|
||||
Pillow==9.4.0
|
||||
|
||||
@ -1,4 +1,64 @@
|
||||
/* 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; }
|
||||
.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
80
static/js/pinboard.js
Normal 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");
|
||||
}
|
||||
});
|
||||
@ -1,21 +1,64 @@
|
||||
|
||||
/* 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; }
|
||||
.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
staticfiles/js/pinboard.js
Normal file
80
staticfiles/js/pinboard.js
Normal 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");
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user