1.0
This commit is contained in:
parent
5c603fd9ab
commit
09fd8f477a
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -150,9 +150,12 @@ STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
path for path in [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
]
|
||||
if path.exists()
|
||||
]
|
||||
|
||||
# Email
|
||||
@ -180,3 +183,8 @@ if EMAIL_USE_SSL:
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
|
||||
# Media uploads
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
@ -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.
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", "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",)
|
||||
|
||||
91
core/forms.py
Normal file
91
core/forms.py
Normal file
@ -0,0 +1,91 @@
|
||||
from django import forms
|
||||
|
||||
from .models import PropertyEntry, PropertyFlag, PropertySuggestion
|
||||
|
||||
|
||||
class BootstrapFormMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field in self.fields.values():
|
||||
widget = field.widget
|
||||
if widget.__class__.__name__ == "HiddenInput":
|
||||
continue
|
||||
current = widget.attrs.get("class", "")
|
||||
if widget.__class__.__name__ == "Select":
|
||||
widget.attrs["class"] = f"form-select {current}".strip()
|
||||
elif widget.__class__.__name__ == "Textarea":
|
||||
widget.attrs["class"] = f"form-control {current}".strip()
|
||||
else:
|
||||
widget.attrs["class"] = f"form-control {current}".strip()
|
||||
|
||||
|
||||
class PropertyLocationForm(BootstrapFormMixin, forms.ModelForm):
|
||||
latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
|
||||
longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
|
||||
|
||||
class Meta:
|
||||
model = PropertyEntry
|
||||
fields = ["address", "latitude", "longitude", "phone", "email", "listing_type"]
|
||||
widgets = {
|
||||
"address": forms.TextInput(attrs={"placeholder": "Street, area, city"}),
|
||||
"phone": forms.TextInput(attrs={"placeholder": "+34 600 000 000"}),
|
||||
"email": forms.EmailInput(attrs={"placeholder": "owner@example.com"}),
|
||||
"listing_type": forms.Select(),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
address = cleaned.get("address")
|
||||
latitude = cleaned.get("latitude")
|
||||
longitude = cleaned.get("longitude")
|
||||
if not address and (latitude is None or longitude is None):
|
||||
raise forms.ValidationError("Add an address or allow location so this property can be placed on the pinboard.")
|
||||
return cleaned
|
||||
|
||||
|
||||
class PropertyPhotoForm(BootstrapFormMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PropertyEntry
|
||||
fields = ["photo", "address", "phone", "email", "listing_type"]
|
||||
widgets = {
|
||||
"address": forms.TextInput(attrs={"placeholder": "Optional if the photo contains GPS or visible text"}),
|
||||
"phone": forms.TextInput(attrs={"placeholder": "Optional contact phone"}),
|
||||
"email": forms.EmailInput(attrs={"placeholder": "Optional contact email"}),
|
||||
"listing_type": forms.Select(),
|
||||
}
|
||||
|
||||
def clean_photo(self):
|
||||
photo = self.cleaned_data.get("photo")
|
||||
if not photo:
|
||||
raise forms.ValidationError("Choose a property photo to upload.")
|
||||
if photo.size > 12 * 1024 * 1024:
|
||||
raise forms.ValidationError("Please upload an image under 12MB for this MVP.")
|
||||
return photo
|
||||
|
||||
|
||||
class PropertySuggestionForm(BootstrapFormMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PropertySuggestion
|
||||
fields = ["address", "phone", "email", "listing_type", "note"]
|
||||
widgets = {
|
||||
"address": forms.TextInput(attrs={"placeholder": "Correct or missing address"}),
|
||||
"phone": forms.TextInput(attrs={"placeholder": "Correct or missing phone"}),
|
||||
"email": forms.EmailInput(attrs={"placeholder": "Correct or missing email"}),
|
||||
"listing_type": forms.Select(),
|
||||
"note": forms.Textarea(attrs={"rows": 3, "placeholder": "What should be updated?"}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
if not any(cleaned.get(field) for field in self.fields):
|
||||
raise forms.ValidationError("Add at least one suggested detail.")
|
||||
return cleaned
|
||||
|
||||
|
||||
class PropertyFlagForm(BootstrapFormMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PropertyFlag
|
||||
fields = ["reason"]
|
||||
widgets = {
|
||||
"reason": forms.TextInput(attrs={"placeholder": "Duplicate, spam, private info, already removed..."}),
|
||||
}
|
||||
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,73 @@
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class PropertyEntry(models.Model):
|
||||
class ListingType(models.TextChoices):
|
||||
SALE = "sale", "For sale"
|
||||
RENTAL = "rental", "For rent"
|
||||
UNKNOWN = "unknown", "Not sure"
|
||||
|
||||
class Source(models.TextChoices):
|
||||
CURRENT_LOCATION = "current_location", "Current location"
|
||||
PHOTO = "photo", "Photo upload"
|
||||
|
||||
source = models.CharField(max_length=32, choices=Source.choices)
|
||||
address = models.CharField(max_length=255, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
phone = models.CharField(max_length=40, blank=True)
|
||||
email = models.EmailField(blank=True)
|
||||
listing_type = models.CharField(max_length=20, choices=ListingType.choices, default=ListingType.UNKNOWN)
|
||||
photo = models.ImageField(upload_to="properties/%Y/%m/", blank=True)
|
||||
extracted_text = models.TextField(blank=True)
|
||||
has_gps_data = models.BooleanField(default=False)
|
||||
idealista_url = models.URLField(blank=True)
|
||||
is_flagged = models.BooleanField(default=False)
|
||||
flag_count = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name_plural = "property entries"
|
||||
|
||||
def __str__(self):
|
||||
label = self.address or f"{self.get_source_display()} #{self.pk}"
|
||||
return f"{label} ({self.get_listing_type_display()})"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("property_detail", args=[self.pk])
|
||||
|
||||
@property
|
||||
def has_location(self):
|
||||
return self.latitude is not None and self.longitude is not None
|
||||
|
||||
|
||||
class PropertySuggestion(models.Model):
|
||||
property_entry = models.ForeignKey(PropertyEntry, on_delete=models.CASCADE, related_name="suggestions")
|
||||
address = models.CharField(max_length=255, blank=True)
|
||||
phone = models.CharField(max_length=40, blank=True)
|
||||
email = models.EmailField(blank=True)
|
||||
listing_type = models.CharField(max_length=20, choices=PropertyEntry.ListingType.choices, blank=True)
|
||||
note = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Suggestion for #{self.property_entry_id}"
|
||||
|
||||
|
||||
class PropertyFlag(models.Model):
|
||||
property_entry = models.ForeignKey(PropertyEntry, on_delete=models.CASCADE, related_name="flags")
|
||||
reason = models.CharField(max_length=160)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Flag for #{self.property_entry_id}: {self.reason}"
|
||||
|
||||
@ -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 current location →</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, then submit an address or GPS coordinates with 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>Idealista link</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>
|
||||
64
core/templates/core/property_detail.html
Normal file
64
core/templates/core/property_detail.html
Normal file
@ -0,0 +1,64 @@
|
||||
{% 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">Open best-effort Idealista search</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 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 %}
|
||||
32
core/templates/core/property_form_location.html
Normal file
32
core/templates/core/property_form_location.html
Normal file
@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="page-shell">
|
||||
<div class="container narrow-container">
|
||||
<div class="form-card">
|
||||
<p class="eyebrow">Create from current location</p>
|
||||
<h1>Add a property pin</h1>
|
||||
<p class="text-muted">Address or GPS is required. Contact details and sale/rental type help others recognize the listing.</p>
|
||||
<button class="btn btn-ghost mb-3" type="button" data-action="fill-current-location">Use my current location</button>
|
||||
<small id="form-location-status" class="status-text d-block mb-3">Location not captured yet.</small>
|
||||
<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.help_text %}<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 %}
|
||||
29
core/templates/core/property_form_photo.html
Normal file
29
core/templates/core/property_form_photo.html
Normal file
@ -0,0 +1,29 @@
|
||||
{% 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 if a system OCR binary is installed.</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 %}
|
||||
{% 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 %}
|
||||
18
core/urls.py
18
core/urls.py
@ -1,7 +1,23 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import home
|
||||
from .views import (
|
||||
add_location_property,
|
||||
add_photo_property,
|
||||
flag_property,
|
||||
home,
|
||||
onboarding,
|
||||
property_detail,
|
||||
property_list,
|
||||
suggest_property_update,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", home, name="home"),
|
||||
path("onboarding/", onboarding, name="onboarding"),
|
||||
path("properties/", property_list, name="property_list"),
|
||||
path("properties/add/location/", add_location_property, name="add_location_property"),
|
||||
path("properties/add/photo/", add_photo_property, name="add_photo_property"),
|
||||
path("properties/<int:pk>/", property_detail, name="property_detail"),
|
||||
path("properties/<int:pk>/suggest/", suggest_property_update, name="suggest_property_update"),
|
||||
path("properties/<int:pk>/flag/", flag_property, name="flag_property"),
|
||||
]
|
||||
|
||||
189
core/views.py
189
core/views.py
@ -1,25 +1,178 @@
|
||||
import os
|
||||
import platform
|
||||
import math
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from django import get_version as django_version
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from .forms import PropertyFlagForm, PropertyLocationForm, PropertyPhotoForm, PropertySuggestionForm
|
||||
from .image_tools import compress_image, ocr_text_best_effort
|
||||
from .models import PropertyEntry
|
||||
|
||||
|
||||
def build_idealista_url(entry):
|
||||
query = entry.address or " ".join(filter(None, [entry.phone, entry.email]))
|
||||
if not query:
|
||||
return ""
|
||||
return f"https://www.idealista.com/en/search/{quote_plus(query)}/"
|
||||
|
||||
|
||||
def _distance_km(lat1, lng1, lat2, lng2):
|
||||
if None in (lat1, lng1, lat2, lng2):
|
||||
return None
|
||||
radius = 6371
|
||||
phi1, phi2 = math.radians(float(lat1)), math.radians(float(lat2))
|
||||
d_phi = math.radians(float(lat2) - float(lat1))
|
||||
d_lambda = math.radians(float(lng2) - float(lng1))
|
||||
a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
|
||||
return radius * (2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)))
|
||||
|
||||
|
||||
def _decorate_distances(entries, user_lat, user_lng):
|
||||
decorated = []
|
||||
for entry in entries:
|
||||
distance = _distance_km(user_lat, user_lng, entry.latitude, entry.longitude) if user_lat and user_lng else None
|
||||
entry.distance_km = distance
|
||||
decorated.append(entry)
|
||||
return decorated
|
||||
|
||||
|
||||
def home(request):
|
||||
"""Render the landing screen with loader and environment details."""
|
||||
host_name = request.get_host().lower()
|
||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||
now = timezone.now()
|
||||
|
||||
entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at")[:6])
|
||||
context = {
|
||||
"project_name": "New Style",
|
||||
"agent_brand": agent_brand,
|
||||
"django_version": django_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"current_time": now,
|
||||
"host_name": host_name,
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
"page_title": "NearbyNest — mobile property pinboard",
|
||||
"meta_description": "A mobile-first PWA for posting nearby property sightings by current location or photo.",
|
||||
"recent_entries": entries,
|
||||
"total_entries": PropertyEntry.objects.count(),
|
||||
"photo_entries": PropertyEntry.objects.exclude(photo="").count(),
|
||||
}
|
||||
return render(request, "core/index.html", context)
|
||||
|
||||
|
||||
def onboarding(request):
|
||||
context = {
|
||||
"page_title": "Onboarding permissions — NearbyNest",
|
||||
"meta_description": "Grant location, notification, and photo permissions for the mobile property pinboard.",
|
||||
}
|
||||
return render(request, "core/onboarding.html", context)
|
||||
|
||||
|
||||
def property_list(request):
|
||||
sort = request.GET.get("sort", "recent")
|
||||
user_lat = request.GET.get("lat")
|
||||
user_lng = request.GET.get("lng")
|
||||
entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at"))
|
||||
entries = _decorate_distances(entries, user_lat, user_lng)
|
||||
if sort == "distance" and user_lat and user_lng:
|
||||
entries.sort(key=lambda entry: entry.distance_km if entry.distance_km is not None else float("inf"))
|
||||
context = {
|
||||
"page_title": "Public property list — NearbyNest",
|
||||
"meta_description": "Browse public property posts by recency or distance from your current location.",
|
||||
"entries": entries,
|
||||
"sort": sort,
|
||||
"user_lat": user_lat or "",
|
||||
"user_lng": user_lng or "",
|
||||
}
|
||||
return render(request, "core/property_list.html", context)
|
||||
|
||||
|
||||
def property_detail(request, pk):
|
||||
entry = get_object_or_404(PropertyEntry.objects.prefetch_related("suggestions", "flags"), pk=pk)
|
||||
context = {
|
||||
"page_title": f"Property #{entry.pk} — NearbyNest",
|
||||
"meta_description": "Review a public property pin, extracted details, community suggestions, and flagging options.",
|
||||
"entry": entry,
|
||||
"suggestion_form": PropertySuggestionForm(),
|
||||
"flag_form": PropertyFlagForm(),
|
||||
}
|
||||
return render(request, "core/property_detail.html", context)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def add_location_property(request):
|
||||
if request.method == "POST":
|
||||
form = PropertyLocationForm(request.POST)
|
||||
if form.is_valid():
|
||||
entry = form.save(commit=False)
|
||||
entry.source = PropertyEntry.Source.CURRENT_LOCATION
|
||||
entry.idealista_url = build_idealista_url(entry)
|
||||
entry.save()
|
||||
messages.success(request, "Property pinned to the public list.")
|
||||
return redirect(entry.get_absolute_url())
|
||||
else:
|
||||
form = PropertyLocationForm()
|
||||
context = {
|
||||
"page_title": "Add current-location property — NearbyNest",
|
||||
"meta_description": "Add a property sighting from your current location, with optional contact details.",
|
||||
"form": form,
|
||||
}
|
||||
return render(request, "core/property_form_location.html", context)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def add_photo_property(request):
|
||||
if request.method == "POST":
|
||||
form = PropertyPhotoForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
uploaded = form.cleaned_data["photo"]
|
||||
processed, error = compress_image(uploaded, uploaded.name)
|
||||
if error:
|
||||
form.add_error("photo", error)
|
||||
else:
|
||||
uploaded.seek(0)
|
||||
entry = form.save(commit=False)
|
||||
entry.source = PropertyEntry.Source.PHOTO
|
||||
entry.photo = processed["file"]
|
||||
if processed["latitude"] is not None and not entry.latitude:
|
||||
entry.latitude = processed["latitude"]
|
||||
entry.longitude = processed["longitude"]
|
||||
entry.has_gps_data = True
|
||||
entry.extracted_text = ocr_text_best_effort(uploaded)
|
||||
entry.idealista_url = build_idealista_url(entry)
|
||||
entry.save()
|
||||
messages.success(request, "Photo compressed under 256KB and added to the pinboard.")
|
||||
return redirect(entry.get_absolute_url())
|
||||
else:
|
||||
form = PropertyPhotoForm()
|
||||
context = {
|
||||
"page_title": "Add property photo — NearbyNest",
|
||||
"meta_description": "Upload a property photo; the MVP compresses it, checks EXIF GPS, and attempts local OCR.",
|
||||
"form": form,
|
||||
}
|
||||
return render(request, "core/property_form_photo.html", context)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def suggest_property_update(request, pk):
|
||||
entry = get_object_or_404(PropertyEntry, pk=pk)
|
||||
if request.method != "POST":
|
||||
return redirect(entry.get_absolute_url())
|
||||
form = PropertySuggestionForm(request.POST)
|
||||
if form.is_valid():
|
||||
suggestion = form.save(commit=False)
|
||||
suggestion.property_entry = entry
|
||||
suggestion.save()
|
||||
messages.success(request, "Thanks — your suggested details were saved for review.")
|
||||
else:
|
||||
messages.error(request, "Please add at least one valid detail before submitting a suggestion.")
|
||||
return redirect(entry.get_absolute_url())
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def flag_property(request, pk):
|
||||
entry = get_object_or_404(PropertyEntry, pk=pk)
|
||||
if request.method != "POST":
|
||||
return redirect(entry.get_absolute_url())
|
||||
form = PropertyFlagForm(request.POST)
|
||||
if form.is_valid():
|
||||
flag = form.save(commit=False)
|
||||
flag.property_entry = entry
|
||||
flag.save()
|
||||
entry.flag_count = entry.flags.count()
|
||||
entry.is_flagged = entry.flag_count >= 3
|
||||
entry.save(update_fields=["flag_count", "is_flagged", "updated_at"])
|
||||
messages.success(request, "Flag saved. Entries with repeated flags are hidden from the public list.")
|
||||
else:
|
||||
messages.error(request, "Add a short reason so reviewers know what to check.")
|
||||
return redirect(entry.get_absolute_url())
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
Django==5.2.7
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
|
||||
Pillow==9.4.0
|
||||
|
||||
@ -1,4 +1,63 @@
|
||||
/* Custom styles for the application */
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
/* NearbyNest custom brand system */
|
||||
:root {
|
||||
--nest-ink: #14213d;
|
||||
--nest-muted: #667085;
|
||||
--nest-bg: #f7f3ea;
|
||||
--nest-surface: #fffaf0;
|
||||
--nest-primary: #0f766e;
|
||||
--nest-primary-dark: #0b4f4a;
|
||||
--nest-secondary: #ffb703;
|
||||
--nest-accent: #ef476f;
|
||||
--nest-mint: #d7fff1;
|
||||
--nest-border: rgba(20, 33, 61, 0.12);
|
||||
--nest-shadow: 0 24px 70px rgba(20, 33, 61, 0.14);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--nest-ink); background: radial-gradient(circle at 8% 0%, rgba(255, 183, 3, 0.24), transparent 28rem), radial-gradient(circle at 92% 16%, rgba(15, 118, 110, 0.18), transparent 30rem), var(--nest-bg); min-height: 100vh; }
|
||||
h1, h2, h3, .brand-mark { font-family: "Plus Jakarta Sans", "Inter", sans-serif; }
|
||||
a { color: var(--nest-primary); text-decoration: none; } a:hover { color: var(--nest-primary-dark); }
|
||||
.site-header { background: rgba(247, 243, 234, 0.78); border-bottom: 1px solid var(--nest-border); backdrop-filter: blur(20px); }
|
||||
.navbar-brand, .nav-link { color: var(--nest-ink); font-weight: 700; } .nav-link:hover, .admin-link { color: var(--nest-primary); } .navbar-toggler { border: 0; }
|
||||
.brand-mark { display: inline-flex; align-items: center; gap: .55rem; font-weight: 800; letter-spacing: -.03em; }
|
||||
.brand-icon { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: 1rem; background: linear-gradient(135deg, var(--nest-primary), #20c997); color: white; box-shadow: 0 12px 30px rgba(15, 118, 110, .28); }
|
||||
.btn { font-weight: 800; border-radius: 999px; padding: .75rem 1.15rem; }
|
||||
.btn-nest { color: white; background: linear-gradient(135deg, var(--nest-primary), #14b8a6); border: 0; box-shadow: 0 16px 36px rgba(15, 118, 110, .28); }
|
||||
.btn-nest:hover { color: white; transform: translateY(-1px); box-shadow: 0 20px 44px rgba(15, 118, 110, .34); }
|
||||
.btn-ghost { color: var(--nest-ink); background: rgba(255,255,255,.62); border: 1px solid rgba(20,33,61,.14); backdrop-filter: blur(14px); }
|
||||
.btn-ghost:hover { background: white; border-color: rgba(15,118,110,.35); }
|
||||
.flash-stack { position: fixed; z-index: 1050; top: 5.25rem; left: 0; right: 0; max-width: 720px; }
|
||||
.hero-section { position: relative; overflow: hidden; padding: clamp(4rem, 8vw, 7.5rem) 0 4rem; }
|
||||
.hero-title { font-size: clamp(3rem, 8vw, 5.8rem); line-height: .92; letter-spacing: -.075em; margin-bottom: 1.2rem; }
|
||||
.hero-copy { max-width: 42rem; color: var(--nest-muted); font-size: clamp(1.05rem, 2vw, 1.28rem); }
|
||||
.eyebrow { text-transform: uppercase; letter-spacing: .16em; color: var(--nest-primary); font-size: .78rem; font-weight: 900; margin-bottom: .8rem; }
|
||||
.permission-strip { display: flex; flex-wrap: wrap; gap: .65rem; }
|
||||
.permission-strip span, .mini-tags span { display: inline-flex; align-items: center; border-radius: 999px; padding: .45rem .75rem; background: rgba(255,255,255,.68); border: 1px solid var(--nest-border); font-size: .83rem; font-weight: 800; }
|
||||
.orb { position: absolute; border-radius: 999px; filter: blur(2px); opacity: .85; pointer-events: none; } .orb-one { width: 8rem; height: 8rem; background: var(--nest-secondary); top: 8rem; right: 12%; } .orb-two { width: 5rem; height: 5rem; background: var(--nest-accent); bottom: 5rem; left: 8%; }
|
||||
.phone-frame { max-width: 23rem; border-radius: 2.5rem; padding: .9rem; background: #102a43; box-shadow: var(--nest-shadow); transform: rotate(2deg); }
|
||||
.phone-top { width: 5rem; height: .35rem; border-radius: 999px; background: rgba(255,255,255,.35); margin: 0 auto .8rem; }
|
||||
.map-card { position: relative; overflow: hidden; min-height: 34rem; border-radius: 2rem; background: linear-gradient(150deg, #d7fff1, #fffaf0 52%, #ffe4ad); }
|
||||
.map-grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(20,33,61,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(20,33,61,.08) 1px, transparent 1px); background-size: 42px 42px; transform: rotate(-12deg) scale(1.15); }
|
||||
.pin { position: absolute; width: 1.4rem; height: 1.4rem; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--nest-accent); box-shadow: 0 10px 22px rgba(239,71,111,.35); }
|
||||
.pin::after { content: ""; position: absolute; inset: .38rem; border-radius: 999px; background: white; } .pin-a { left: 25%; top: 23%; } .pin-b { right: 22%; top: 42%; background: var(--nest-primary); } .pin-c { left: 42%; bottom: 23%; background: var(--nest-secondary); }
|
||||
.glass-panel { position: absolute; left: 1.1rem; right: 1.1rem; bottom: 1.1rem; padding: 1.2rem; border-radius: 1.5rem; background: rgba(255,255,255,.78); border: 1px solid rgba(255,255,255,.8); backdrop-filter: blur(18px); box-shadow: 0 18px 42px rgba(20,33,61,.14); } .glass-panel h2 { margin: .8rem 0 .25rem; font-size: 1.35rem; }
|
||||
.section-pad { padding: 4rem 0; } .section-heading h2, .page-heading h1, .list-hero h1 { font-size: clamp(2rem, 4vw, 3.4rem); letter-spacing: -.055em; }
|
||||
.feature-card, .property-card, .form-card, .detail-panel, .activity-card, .sort-panel, .permission-wizard { background: rgba(255,250,240,.86); border: 1px solid var(--nest-border); border-radius: 2rem; box-shadow: 0 18px 54px rgba(20,33,61,.08); }
|
||||
.feature-card { padding: 1.5rem; } .feature-card h2 { font-size: 1.35rem; margin-top: 1rem; } .feature-card p, .text-muted, .card-meta { color: var(--nest-muted) !important; } .feature-icon, .step-icon { font-size: 2rem; }
|
||||
.property-card { position: relative; overflow: hidden; transition: transform .2s ease, box-shadow .2s ease; } .property-card:hover { transform: translateY(-4px); box-shadow: var(--nest-shadow); }
|
||||
.property-thumb { width: 100%; height: 13rem; object-fit: cover; background: var(--nest-mint); } .placeholder-thumb { display: grid; place-items: center; font-size: 4rem; color: var(--nest-primary); }
|
||||
.property-card-body { padding: 1.15rem; } .property-card h3 { font-size: 1.2rem; margin: .8rem 0 .25rem; } .listing-badge { background: #14213d; color: white; }
|
||||
.mini-tags { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .9rem; } .mini-tags span { padding: .28rem .55rem; font-size: .72rem; }
|
||||
.empty-state { text-align: center; padding: 3rem 1.5rem; border: 1px dashed rgba(20,33,61,.24); border-radius: 2rem; background: rgba(255,255,255,.5); }
|
||||
.site-footer { margin-top: 4rem; padding: 2rem 0; color: var(--nest-muted); border-top: 1px solid var(--nest-border); }
|
||||
.page-shell { padding: 3rem 0 1rem; } .narrow-container { max-width: 780px; } .page-heading { margin-bottom: 2rem; } .page-heading p { color: var(--nest-muted); }
|
||||
.permission-wizard { display: grid; gap: 1rem; padding: 1rem; } .permission-step { display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: center; padding: 1.2rem; border-radius: 1.4rem; background: rgba(255,255,255,.56); }
|
||||
.permission-step h2 { font-size: 1.25rem; margin: .3rem 0; } .status-text { color: var(--nest-muted); font-weight: 700; } .action-dock { display: flex; flex-wrap: wrap; justify-content: center; gap: .8rem; margin-top: 1.4rem; }
|
||||
.form-card { padding: clamp(1.2rem, 4vw, 2rem); } .form-card h1 { letter-spacing: -.055em; }
|
||||
.form-control, .form-select { border-radius: 1rem; border-color: rgba(20,33,61,.16); padding: .82rem 1rem; } .form-control:focus, .form-select:focus { border-color: var(--nest-primary); box-shadow: 0 0 0 .25rem rgba(15,118,110,.12); }
|
||||
.invalid-copy { color: #b42318; font-weight: 700; font-size: .9rem; margin-top: .35rem; }
|
||||
.list-hero { display: flex; justify-content: space-between; align-items: end; gap: 1.5rem; margin-bottom: 1.25rem; } .sort-panel { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; padding: 1rem; margin-bottom: 1rem; } .sort-panel > div { min-width: min(100%, 16rem); }
|
||||
.detail-panel { overflow: hidden; } .detail-photo { width: 100%; max-height: 28rem; object-fit: cover; } .detail-body { padding: clamp(1.3rem, 4vw, 2rem); } .detail-body h1 { margin-top: 1rem; letter-spacing: -.055em; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: .9rem; margin: 1.5rem 0; } .detail-grid div, .analysis-box { padding: 1rem; border-radius: 1.2rem; background: rgba(255,255,255,.62); border: 1px solid var(--nest-border); }
|
||||
.detail-grid dt { color: var(--nest-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .1em; } .detail-grid dd { margin: .2rem 0 0; font-weight: 800; word-break: break-word; }
|
||||
.analysis-box h2, .compact-card h2, .activity-card h2 { font-size: 1.2rem; } .side-stack { display: grid; gap: 1rem; } .compact-card { border-radius: 1.5rem; } .danger-soft { background: rgba(255, 245, 245, .88); } .activity-card { padding: 1.3rem; }
|
||||
@media (max-width: 767px) { .hero-section { padding-top: 3rem; } .phone-frame { transform: none; } .map-card { min-height: 27rem; } .permission-step, .list-hero { display: block; } .permission-step .btn { width: 100%; margin: .75rem 0 .35rem; } .detail-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
56
static/js/pinboard.js
Normal file
56
static/js/pinboard.js
Normal file
@ -0,0 +1,56 @@
|
||||
function setText(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function requestLocation(callback, statusId) {
|
||||
if (!navigator.geolocation) {
|
||||
setText(statusId, "Location is not supported by this browser.");
|
||||
return;
|
||||
}
|
||||
setText(statusId, "Requesting location…");
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude.toFixed(6);
|
||||
const lng = pos.coords.longitude.toFixed(6);
|
||||
setText(statusId, `Captured ${lat}, ${lng}`);
|
||||
callback(lat, lng);
|
||||
},
|
||||
() => setText(statusId, "Location permission was denied or unavailable."),
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const action = event.target?.dataset?.action;
|
||||
if (action === "request-location") {
|
||||
requestLocation(() => {}, "location-status");
|
||||
}
|
||||
if (action === "request-notifications") {
|
||||
if (!window.Notification) {
|
||||
setText("notification-status", "Notifications are not supported here.");
|
||||
return;
|
||||
}
|
||||
Notification.requestPermission().then((permission) => {
|
||||
setText("notification-status", `Notification permission: ${permission}`);
|
||||
});
|
||||
}
|
||||
if (action === "fill-current-location") {
|
||||
requestLocation((lat, lng) => {
|
||||
const latInput = document.getElementById("id_latitude");
|
||||
const lngInput = document.getElementById("id_longitude");
|
||||
if (latInput) latInput.value = lat;
|
||||
if (lngInput) lngInput.value = lng;
|
||||
}, "form-location-status");
|
||||
}
|
||||
if (action === "use-location-for-list") {
|
||||
requestLocation((lat, lng) => {
|
||||
document.querySelector("[data-user-lat]").value = lat;
|
||||
document.querySelector("[data-user-lng]").value = lng;
|
||||
const sort = document.getElementById("sort");
|
||||
if (sort) sort.value = "distance";
|
||||
const form = document.querySelector("[data-distance-form]");
|
||||
if (form) form.submit();
|
||||
}, "list-location-status");
|
||||
}
|
||||
});
|
||||
@ -1,21 +1,63 @@
|
||||
|
||||
/* NearbyNest custom brand system */
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
--nest-ink: #14213d;
|
||||
--nest-muted: #667085;
|
||||
--nest-bg: #f7f3ea;
|
||||
--nest-surface: #fffaf0;
|
||||
--nest-primary: #0f766e;
|
||||
--nest-primary-dark: #0b4f4a;
|
||||
--nest-secondary: #ffb703;
|
||||
--nest-accent: #ef476f;
|
||||
--nest-mint: #d7fff1;
|
||||
--nest-border: rgba(20, 33, 61, 0.12);
|
||||
--nest-shadow: 0 24px 70px rgba(20, 33, 61, 0.14);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--nest-ink); background: radial-gradient(circle at 8% 0%, rgba(255, 183, 3, 0.24), transparent 28rem), radial-gradient(circle at 92% 16%, rgba(15, 118, 110, 0.18), transparent 30rem), var(--nest-bg); min-height: 100vh; }
|
||||
h1, h2, h3, .brand-mark { font-family: "Plus Jakarta Sans", "Inter", sans-serif; }
|
||||
a { color: var(--nest-primary); text-decoration: none; } a:hover { color: var(--nest-primary-dark); }
|
||||
.site-header { background: rgba(247, 243, 234, 0.78); border-bottom: 1px solid var(--nest-border); backdrop-filter: blur(20px); }
|
||||
.navbar-brand, .nav-link { color: var(--nest-ink); font-weight: 700; } .nav-link:hover, .admin-link { color: var(--nest-primary); } .navbar-toggler { border: 0; }
|
||||
.brand-mark { display: inline-flex; align-items: center; gap: .55rem; font-weight: 800; letter-spacing: -.03em; }
|
||||
.brand-icon { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: 1rem; background: linear-gradient(135deg, var(--nest-primary), #20c997); color: white; box-shadow: 0 12px 30px rgba(15, 118, 110, .28); }
|
||||
.btn { font-weight: 800; border-radius: 999px; padding: .75rem 1.15rem; }
|
||||
.btn-nest { color: white; background: linear-gradient(135deg, var(--nest-primary), #14b8a6); border: 0; box-shadow: 0 16px 36px rgba(15, 118, 110, .28); }
|
||||
.btn-nest:hover { color: white; transform: translateY(-1px); box-shadow: 0 20px 44px rgba(15, 118, 110, .34); }
|
||||
.btn-ghost { color: var(--nest-ink); background: rgba(255,255,255,.62); border: 1px solid rgba(20,33,61,.14); backdrop-filter: blur(14px); }
|
||||
.btn-ghost:hover { background: white; border-color: rgba(15,118,110,.35); }
|
||||
.flash-stack { position: fixed; z-index: 1050; top: 5.25rem; left: 0; right: 0; max-width: 720px; }
|
||||
.hero-section { position: relative; overflow: hidden; padding: clamp(4rem, 8vw, 7.5rem) 0 4rem; }
|
||||
.hero-title { font-size: clamp(3rem, 8vw, 5.8rem); line-height: .92; letter-spacing: -.075em; margin-bottom: 1.2rem; }
|
||||
.hero-copy { max-width: 42rem; color: var(--nest-muted); font-size: clamp(1.05rem, 2vw, 1.28rem); }
|
||||
.eyebrow { text-transform: uppercase; letter-spacing: .16em; color: var(--nest-primary); font-size: .78rem; font-weight: 900; margin-bottom: .8rem; }
|
||||
.permission-strip { display: flex; flex-wrap: wrap; gap: .65rem; }
|
||||
.permission-strip span, .mini-tags span { display: inline-flex; align-items: center; border-radius: 999px; padding: .45rem .75rem; background: rgba(255,255,255,.68); border: 1px solid var(--nest-border); font-size: .83rem; font-weight: 800; }
|
||||
.orb { position: absolute; border-radius: 999px; filter: blur(2px); opacity: .85; pointer-events: none; } .orb-one { width: 8rem; height: 8rem; background: var(--nest-secondary); top: 8rem; right: 12%; } .orb-two { width: 5rem; height: 5rem; background: var(--nest-accent); bottom: 5rem; left: 8%; }
|
||||
.phone-frame { max-width: 23rem; border-radius: 2.5rem; padding: .9rem; background: #102a43; box-shadow: var(--nest-shadow); transform: rotate(2deg); }
|
||||
.phone-top { width: 5rem; height: .35rem; border-radius: 999px; background: rgba(255,255,255,.35); margin: 0 auto .8rem; }
|
||||
.map-card { position: relative; overflow: hidden; min-height: 34rem; border-radius: 2rem; background: linear-gradient(150deg, #d7fff1, #fffaf0 52%, #ffe4ad); }
|
||||
.map-grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(20,33,61,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(20,33,61,.08) 1px, transparent 1px); background-size: 42px 42px; transform: rotate(-12deg) scale(1.15); }
|
||||
.pin { position: absolute; width: 1.4rem; height: 1.4rem; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--nest-accent); box-shadow: 0 10px 22px rgba(239,71,111,.35); }
|
||||
.pin::after { content: ""; position: absolute; inset: .38rem; border-radius: 999px; background: white; } .pin-a { left: 25%; top: 23%; } .pin-b { right: 22%; top: 42%; background: var(--nest-primary); } .pin-c { left: 42%; bottom: 23%; background: var(--nest-secondary); }
|
||||
.glass-panel { position: absolute; left: 1.1rem; right: 1.1rem; bottom: 1.1rem; padding: 1.2rem; border-radius: 1.5rem; background: rgba(255,255,255,.78); border: 1px solid rgba(255,255,255,.8); backdrop-filter: blur(18px); box-shadow: 0 18px 42px rgba(20,33,61,.14); } .glass-panel h2 { margin: .8rem 0 .25rem; font-size: 1.35rem; }
|
||||
.section-pad { padding: 4rem 0; } .section-heading h2, .page-heading h1, .list-hero h1 { font-size: clamp(2rem, 4vw, 3.4rem); letter-spacing: -.055em; }
|
||||
.feature-card, .property-card, .form-card, .detail-panel, .activity-card, .sort-panel, .permission-wizard { background: rgba(255,250,240,.86); border: 1px solid var(--nest-border); border-radius: 2rem; box-shadow: 0 18px 54px rgba(20,33,61,.08); }
|
||||
.feature-card { padding: 1.5rem; } .feature-card h2 { font-size: 1.35rem; margin-top: 1rem; } .feature-card p, .text-muted, .card-meta { color: var(--nest-muted) !important; } .feature-icon, .step-icon { font-size: 2rem; }
|
||||
.property-card { position: relative; overflow: hidden; transition: transform .2s ease, box-shadow .2s ease; } .property-card:hover { transform: translateY(-4px); box-shadow: var(--nest-shadow); }
|
||||
.property-thumb { width: 100%; height: 13rem; object-fit: cover; background: var(--nest-mint); } .placeholder-thumb { display: grid; place-items: center; font-size: 4rem; color: var(--nest-primary); }
|
||||
.property-card-body { padding: 1.15rem; } .property-card h3 { font-size: 1.2rem; margin: .8rem 0 .25rem; } .listing-badge { background: #14213d; color: white; }
|
||||
.mini-tags { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .9rem; } .mini-tags span { padding: .28rem .55rem; font-size: .72rem; }
|
||||
.empty-state { text-align: center; padding: 3rem 1.5rem; border: 1px dashed rgba(20,33,61,.24); border-radius: 2rem; background: rgba(255,255,255,.5); }
|
||||
.site-footer { margin-top: 4rem; padding: 2rem 0; color: var(--nest-muted); border-top: 1px solid var(--nest-border); }
|
||||
.page-shell { padding: 3rem 0 1rem; } .narrow-container { max-width: 780px; } .page-heading { margin-bottom: 2rem; } .page-heading p { color: var(--nest-muted); }
|
||||
.permission-wizard { display: grid; gap: 1rem; padding: 1rem; } .permission-step { display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: center; padding: 1.2rem; border-radius: 1.4rem; background: rgba(255,255,255,.56); }
|
||||
.permission-step h2 { font-size: 1.25rem; margin: .3rem 0; } .status-text { color: var(--nest-muted); font-weight: 700; } .action-dock { display: flex; flex-wrap: wrap; justify-content: center; gap: .8rem; margin-top: 1.4rem; }
|
||||
.form-card { padding: clamp(1.2rem, 4vw, 2rem); } .form-card h1 { letter-spacing: -.055em; }
|
||||
.form-control, .form-select { border-radius: 1rem; border-color: rgba(20,33,61,.16); padding: .82rem 1rem; } .form-control:focus, .form-select:focus { border-color: var(--nest-primary); box-shadow: 0 0 0 .25rem rgba(15,118,110,.12); }
|
||||
.invalid-copy { color: #b42318; font-weight: 700; font-size: .9rem; margin-top: .35rem; }
|
||||
.list-hero { display: flex; justify-content: space-between; align-items: end; gap: 1.5rem; margin-bottom: 1.25rem; } .sort-panel { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; padding: 1rem; margin-bottom: 1rem; } .sort-panel > div { min-width: min(100%, 16rem); }
|
||||
.detail-panel { overflow: hidden; } .detail-photo { width: 100%; max-height: 28rem; object-fit: cover; } .detail-body { padding: clamp(1.3rem, 4vw, 2rem); } .detail-body h1 { margin-top: 1rem; letter-spacing: -.055em; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: .9rem; margin: 1.5rem 0; } .detail-grid div, .analysis-box { padding: 1rem; border-radius: 1.2rem; background: rgba(255,255,255,.62); border: 1px solid var(--nest-border); }
|
||||
.detail-grid dt { color: var(--nest-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .1em; } .detail-grid dd { margin: .2rem 0 0; font-weight: 800; word-break: break-word; }
|
||||
.analysis-box h2, .compact-card h2, .activity-card h2 { font-size: 1.2rem; } .side-stack { display: grid; gap: 1rem; } .compact-card { border-radius: 1.5rem; } .danger-soft { background: rgba(255, 245, 245, .88); } .activity-card { padding: 1.3rem; }
|
||||
@media (max-width: 767px) { .hero-section { padding-top: 3rem; } .phone-frame { transform: none; } .map-card { min-height: 27rem; } .permission-step, .list-hero { display: block; } .permission-step .btn { width: 100%; margin: .75rem 0 .35rem; } .detail-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
56
staticfiles/js/pinboard.js
Normal file
56
staticfiles/js/pinboard.js
Normal file
@ -0,0 +1,56 @@
|
||||
function setText(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function requestLocation(callback, statusId) {
|
||||
if (!navigator.geolocation) {
|
||||
setText(statusId, "Location is not supported by this browser.");
|
||||
return;
|
||||
}
|
||||
setText(statusId, "Requesting location…");
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude.toFixed(6);
|
||||
const lng = pos.coords.longitude.toFixed(6);
|
||||
setText(statusId, `Captured ${lat}, ${lng}`);
|
||||
callback(lat, lng);
|
||||
},
|
||||
() => setText(statusId, "Location permission was denied or unavailable."),
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const action = event.target?.dataset?.action;
|
||||
if (action === "request-location") {
|
||||
requestLocation(() => {}, "location-status");
|
||||
}
|
||||
if (action === "request-notifications") {
|
||||
if (!window.Notification) {
|
||||
setText("notification-status", "Notifications are not supported here.");
|
||||
return;
|
||||
}
|
||||
Notification.requestPermission().then((permission) => {
|
||||
setText("notification-status", `Notification permission: ${permission}`);
|
||||
});
|
||||
}
|
||||
if (action === "fill-current-location") {
|
||||
requestLocation((lat, lng) => {
|
||||
const latInput = document.getElementById("id_latitude");
|
||||
const lngInput = document.getElementById("id_longitude");
|
||||
if (latInput) latInput.value = lat;
|
||||
if (lngInput) lngInput.value = lng;
|
||||
}, "form-location-status");
|
||||
}
|
||||
if (action === "use-location-for-list") {
|
||||
requestLocation((lat, lng) => {
|
||||
document.querySelector("[data-user-lat]").value = lat;
|
||||
document.querySelector("[data-user-lng]").value = lng;
|
||||
const sort = document.getElementById("sort");
|
||||
if (sort) sort.value = "distance";
|
||||
const form = document.querySelector("[data-distance-form]");
|
||||
if (form) form.submit();
|
||||
}, "list-location-status");
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user