From 09fd8f477a10c27d9a4c69a8b2586eab16d2cc7f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 4 Jun 2026 16:38:15 +0000 Subject: [PATCH] 1.0 --- config/__pycache__/__init__.cpython-311.pyc | Bin 159 -> 159 bytes config/__pycache__/settings.cpython-311.pyc | Bin 5552 -> 5867 bytes config/__pycache__/urls.cpython-311.pyc | Bin 1557 -> 1663 bytes config/__pycache__/wsgi.cpython-311.pyc | Bin 679 -> 679 bytes config/settings.py | 14 +- config/urls.py | 1 + core/__pycache__/__init__.cpython-311.pyc | Bin 157 -> 157 bytes core/__pycache__/admin.cpython-311.pyc | Bin 212 -> 2291 bytes core/__pycache__/apps.cpython-311.pyc | Bin 524 -> 524 bytes .../context_processors.cpython-311.pyc | Bin 763 -> 763 bytes core/__pycache__/forms.cpython-311.pyc | Bin 0 -> 7385 bytes core/__pycache__/image_tools.cpython-311.pyc | Bin 0 -> 6268 bytes core/__pycache__/models.cpython-311.pyc | Bin 209 -> 6187 bytes core/__pycache__/urls.cpython-311.pyc | Bin 347 -> 1142 bytes core/__pycache__/views.cpython-311.pyc | Bin 1364 -> 11810 bytes core/admin.py | 35 ++- core/forms.py | 91 +++++++ core/image_tools.py | 104 ++++++++ core/migrations/0001_initial.py | 68 +++++ .../__pycache__/0001_initial.cpython-311.pyc | Bin 0 -> 4027 bytes .../__pycache__/__init__.cpython-311.pyc | Bin 168 -> 168 bytes core/models.py | 72 +++++- core/templates/base.html | 52 +++- core/templates/core/index.html | 234 +++++++----------- core/templates/core/onboarding.html | 34 +++ .../core/partials/property_card.html | 24 ++ core/templates/core/property_detail.html | 64 +++++ .../core/property_form_location.html | 32 +++ core/templates/core/property_form_photo.html | 29 +++ core/templates/core/property_list.html | 50 ++++ core/urls.py | 18 +- core/views.py | 189 ++++++++++++-- requirements.txt | 2 + static/css/custom.css | 65 ++++- static/js/pinboard.js | 56 +++++ staticfiles/css/custom.css | 80 ++++-- staticfiles/js/pinboard.js | 56 +++++ 37 files changed, 1182 insertions(+), 188 deletions(-) create mode 100644 core/__pycache__/forms.cpython-311.pyc create mode 100644 core/__pycache__/image_tools.cpython-311.pyc create mode 100644 core/forms.py create mode 100644 core/image_tools.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/__pycache__/0001_initial.cpython-311.pyc create mode 100644 core/templates/core/onboarding.html create mode 100644 core/templates/core/partials/property_card.html create mode 100644 core/templates/core/property_detail.html create mode 100644 core/templates/core/property_form_location.html create mode 100644 core/templates/core/property_form_photo.html create mode 100644 core/templates/core/property_list.html create mode 100644 static/js/pinboard.js create mode 100644 staticfiles/js/pinboard.js diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc index 896bb4f1b8949c96d2f1bf743293342b70a9d0e4..bad71c0585b03bb8ac1ea49be6ce759340a993f4 100644 GIT binary patch delta 20 acmbQwIG>ScIWI340}#Afu9!8EXEFdYI|XI{ delta 20 acmbQwIG>ScIWI340}#~ttjL_mGZ_Fdwgn>q diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a79890a58443ee5f65d8d558823a19e9aed..f77a9c1a223d0b61cbccc6ce33ee354c44fbd5cf 100644 GIT binary patch delta 673 zcmdm>{aTlIIWI340}#w!shD+YBCjOlw~gvonVeD#S{PC}Qw*!P89=lVlr{#@Y0NGR z%Ya%|gYX~m_gHh6Kg$(QZggVF+iFHh?#--vlWn-&XCSf z!w@Smc^$jd(rR%8~J6l*dS@d5>Iu@oehWK90Y zC+0Q-$St-25)BL=7}!Oa5X22$!4F&v96asSP1TLHEwvwbfzmfvI6g3=C@T^KYTjJW zJC%`f?PO8@2B9i;{oK@)%tZZLtYBKxaPxHj!;Ge8Ky^jtK;jm2W?o4V$j%~=>x-;G zgbj$W1rhc@;udRhK~82#k@Mt!L2;W~oW8Cuo{sUMK|Z&*Aj}|t{}8a>z}6S>1NHpk zu*uC&Da}c>E6M?KL7`R5!m|0a;6x@X7MPPCa0pDWxX2-W1Bte~$f5Xwokffd?6Z%c pQ1}8OE-(mu5ZPQWqQ)rofrEiptby|eujmZ+i@fp;Tp$Rx8UV9cn5h5& delta 356 zcmaE@yFr_GIWI340}#~ttjKhk$ScWsWuy94CcRY76oV>m1_mH)2&IicbUJgC5)(rz za~4<=NMnj|ipgX#7Gr=~+ZW$8;!)hRgfGF)0`xXW?RSuJPv8XdTPJYCq%Ig%Rnc^I!m%<#( zpy{$%iM5_%b1vUhM#fc>nFSh{H1#)66*$ajY78{3$OK5-V$RGfDKZ6d%|V0(h_D0^ zRv^L#NZevAF38C&DRP+HDuGh>PzrZ+;*&kx7}2ft9zT;v$Rq4Hh0a iU3Za1_5(YM2-5`yfe(C}SBt7Ka$jH&0-_>8pk@I1TTP|T{G$ScWcH&J~7qu<06DPpNSObn@ delta 92 zcmey*GnI#TIWI340}#~ttjN@v$ScXnHBo&5qshb*DVq}*e={)(PL5$&Fw diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc index a1b4aa75cef92df0f6f3c8f77e2be44ec1b36534..ca55c1188411cebaf14ff59d083144a818b5dcef 100644 GIT binary patch delta 21 bcmZ3^x}23~IWI340}#Afu9&rvXEqZ6I|~J< delta 21 bcmZ3^x}23~IWI340}#~ttjOHRGn)wjI70Y}T7@pY=+i})Tnx-KIlDM>#8nv-hi33oOP*f_QNs-EFFRP7bYHzyUHM6^- zz8R@MfLrqm0#T0q5l8aDno}iCy*WxwJ@LM?UOTY^syZ7#*`0acXWoxz=Fd{8NMQW_ z*Nwfu4MP6Gk8I>dFQ-jP$ScAKqdsX-n^M9wru$k;xAm4`8!gi|DbdIy!i<-MnVjxr zzT`kB>Sf!Z$qxm zGW`(vUciM0u7^|>>w>#6XO0{1RP(anIDy;Zjw6eX(+XMQEFq{smho*R19Y89VfFG9(CI=7*c=?Ew*e*%hUV(fT!ZP*WW&z! zJTEjfdybjV?A$~)N>ksV+@PhJ$7J4ROzWYLQpG-_}Lizq^AwwqzVK@Tn0^JQ7dqK>+X z;kBwMB3Vq@u)|1f#i{D1u{q3(+P>S7^B5_}$&a~^rHH$t-W;hbExZy7R!-(UCFy9o z7poXB0`@4n2QZP->#KX+$AhIGyFU$-bI+j`Q*1D`QSr4I%-mcJLUgp&06bTbP(nG86 ztkN&;ZsNOQbkZjRDhS>HwSmxz+jy<1Jd|=&1yx*A0lzIgNUz6l#TT#(iX!p>hdQNn zKq|e9f3L|_@4x?sIi21fl3CbK`WyF8aN%sSYR%dy-~DU->vw02iMH6i_HzA?(lK4{ z)AgZd(r<=j7SOwTf(vH|CpytK#`cRNmfqqg$Mj~O-W+Ng{c1>NVW0QcH&1Zk3;}6w mjqQtBFxD|$>(e!4sUXWV?3?}7JIGQ&mI|^!@YD86xc>qpLIr^U delta 131 zcmew?c!kk_IWI340}#~ttjM$n(vLwL7+``jJ_`XE(-~42QW$d>av7r-85vTTf*CZK zUxE~9GTvfMOv%m6^V4LS{Es&fI0#kn&dbZi00eKAD`qX^VFCa!bOgl! delta 20 acmeBS>0#kn&dbZi00ebDD>4`IFaZE9?*xJX diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc index 75bf2234fb21a6b62efc5cec11af9512dd0c9616..b1f708f45edf9b28e7962bc9b86b640153fc3d52 100644 GIT binary patch delta 21 bcmey(`kR$!IWI340}#Afu9&rv=RFeuNYe(S delta 21 bcmey(`kR$!IWI340}xbw%g@}%^PUL+NjnD4 diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6990f960ed8c0d789b4d59592a5740cc8d89a94c GIT binary patch literal 7385 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5fb7c2b603c587f8162d643eb10596d2858..ca61d903e5409929fb39446a527e4716a8483cba 100644 GIT binary patch literal 6187 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 Xi)Det2WCb_#v2SW7qFosHlPXst*axs diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988a223abc7b5af333902925bc196186bbf5..1c047596f4769ffc3b7b74b65ae6ef474622ad81 100644 GIT binary patch literal 1142 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 Xr9HlS=yH2}4P;gNa)91vejfh;`i@)& diff --git a/core/admin.py b/core/admin.py index 8c38f3f..2676b11 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,36 @@ from django.contrib import admin -# Register your models here. +from .models import PropertyEntry, PropertyFlag, PropertySuggestion + + +class PropertySuggestionInline(admin.TabularInline): + model = PropertySuggestion + extra = 0 + readonly_fields = ("created_at",) + + +class PropertyFlagInline(admin.TabularInline): + model = PropertyFlag + extra = 0 + readonly_fields = ("created_at",) + + +@admin.register(PropertyEntry) +class PropertyEntryAdmin(admin.ModelAdmin): + list_display = ("id", "address", "listing_type", "source", "has_gps_data", "flag_count", "created_at") + list_filter = ("listing_type", "source", "has_gps_data", "is_flagged") + search_fields = ("address", "phone", "email", "extracted_text") + readonly_fields = ("created_at", "updated_at") + inlines = [PropertySuggestionInline, PropertyFlagInline] + + +@admin.register(PropertySuggestion) +class PropertySuggestionAdmin(admin.ModelAdmin): + list_display = ("id", "property_entry", "address", "email", "created_at") + search_fields = ("address", "phone", "email", "note") + + +@admin.register(PropertyFlag) +class PropertyFlagAdmin(admin.ModelAdmin): + list_display = ("id", "property_entry", "reason", "created_at") + search_fields = ("reason",) diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..643b821 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,91 @@ +from django import forms + +from .models import PropertyEntry, PropertyFlag, PropertySuggestion + + +class BootstrapFormMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + widget = field.widget + if widget.__class__.__name__ == "HiddenInput": + continue + current = widget.attrs.get("class", "") + if widget.__class__.__name__ == "Select": + widget.attrs["class"] = f"form-select {current}".strip() + elif widget.__class__.__name__ == "Textarea": + widget.attrs["class"] = f"form-control {current}".strip() + else: + widget.attrs["class"] = f"form-control {current}".strip() + + +class PropertyLocationForm(BootstrapFormMixin, forms.ModelForm): + latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) + longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) + + class Meta: + model = PropertyEntry + fields = ["address", "latitude", "longitude", "phone", "email", "listing_type"] + widgets = { + "address": forms.TextInput(attrs={"placeholder": "Street, area, city"}), + "phone": forms.TextInput(attrs={"placeholder": "+34 600 000 000"}), + "email": forms.EmailInput(attrs={"placeholder": "owner@example.com"}), + "listing_type": forms.Select(), + } + + def clean(self): + cleaned = super().clean() + address = cleaned.get("address") + latitude = cleaned.get("latitude") + longitude = cleaned.get("longitude") + if not address and (latitude is None or longitude is None): + raise forms.ValidationError("Add an address or allow location so this property can be placed on the pinboard.") + return cleaned + + +class PropertyPhotoForm(BootstrapFormMixin, forms.ModelForm): + class Meta: + model = PropertyEntry + fields = ["photo", "address", "phone", "email", "listing_type"] + widgets = { + "address": forms.TextInput(attrs={"placeholder": "Optional if the photo contains GPS or visible text"}), + "phone": forms.TextInput(attrs={"placeholder": "Optional contact phone"}), + "email": forms.EmailInput(attrs={"placeholder": "Optional contact email"}), + "listing_type": forms.Select(), + } + + def clean_photo(self): + photo = self.cleaned_data.get("photo") + if not photo: + raise forms.ValidationError("Choose a property photo to upload.") + if photo.size > 12 * 1024 * 1024: + raise forms.ValidationError("Please upload an image under 12MB for this MVP.") + return photo + + +class PropertySuggestionForm(BootstrapFormMixin, forms.ModelForm): + class Meta: + model = PropertySuggestion + fields = ["address", "phone", "email", "listing_type", "note"] + widgets = { + "address": forms.TextInput(attrs={"placeholder": "Correct or missing address"}), + "phone": forms.TextInput(attrs={"placeholder": "Correct or missing phone"}), + "email": forms.EmailInput(attrs={"placeholder": "Correct or missing email"}), + "listing_type": forms.Select(), + "note": forms.Textarea(attrs={"rows": 3, "placeholder": "What should be updated?"}), + } + + def clean(self): + cleaned = super().clean() + if not any(cleaned.get(field) for field in self.fields): + raise forms.ValidationError("Add at least one suggested detail.") + return cleaned + + +class PropertyFlagForm(BootstrapFormMixin, forms.ModelForm): + class Meta: + model = PropertyFlag + fields = ["reason"] + widgets = { + "reason": forms.TextInput(attrs={"placeholder": "Duplicate, spam, private info, already removed..."}), + } diff --git a/core/image_tools.py b/core/image_tools.py new file mode 100644 index 0000000..dc62485 --- /dev/null +++ b/core/image_tools.py @@ -0,0 +1,104 @@ +import io +import shutil +import subprocess +import tempfile +from decimal import Decimal +from pathlib import Path + +from django.core.files.base import ContentFile +from PIL import Image, UnidentifiedImageError + +GPS_TAG = 34853 +MAX_BYTES = 256 * 1024 + + +def _ratio_to_float(value): + try: + return float(value.numerator) / float(value.denominator) + except AttributeError: + return float(value) + + +def _gps_to_decimal(parts, ref): + degrees = _ratio_to_float(parts[0]) + minutes = _ratio_to_float(parts[1]) + seconds = _ratio_to_float(parts[2]) + result = degrees + minutes / 60 + seconds / 3600 + if ref in ("S", "W"): + result = -result + return Decimal(str(round(result, 6))) + + +def extract_gps(image): + try: + exif = image.getexif() + gps = exif.get_ifd(GPS_TAG) if exif else None + if not gps: + return None, None + lat = gps.get(2) + lat_ref = gps.get(1) + lng = gps.get(4) + lng_ref = gps.get(3) + if not (lat and lat_ref and lng and lng_ref): + return None, None + return _gps_to_decimal(lat, lat_ref), _gps_to_decimal(lng, lng_ref) + except Exception: + return None, None + + +def compress_image(uploaded_file, base_name="property"): + try: + uploaded_file.seek(0) + image = Image.open(uploaded_file) + image.load() + except (UnidentifiedImageError, OSError): + return None, "The uploaded file is not a readable image." + + latitude, longitude = extract_gps(image) + image = image.convert("RGB") + image.thumbnail((1600, 1600)) + + quality = 86 + output = io.BytesIO() + while quality >= 45: + output.seek(0) + output.truncate(0) + image.save(output, format="JPEG", optimize=True, progressive=True, quality=quality) + if output.tell() <= MAX_BYTES: + break + quality -= 7 + + while output.tell() > MAX_BYTES and image.width > 640 and image.height > 640: + image.thumbnail((int(image.width * 0.82), int(image.height * 0.82))) + output.seek(0) + output.truncate(0) + image.save(output, format="JPEG", optimize=True, progressive=True, quality=max(quality, 45)) + + filename = f"{Path(base_name).stem or 'property'}-small.jpg" + return { + "file": ContentFile(output.getvalue(), name=filename), + "latitude": latitude, + "longitude": longitude, + "size": output.tell(), + }, "" + + +def ocr_text_best_effort(uploaded_file): + """Use locally installed system OCR only. Returns blank when unavailable.""" + if not shutil.which("tesseract"): + return "" + try: + uploaded_file.seek(0) + with tempfile.NamedTemporaryFile(suffix=Path(uploaded_file.name).suffix or ".jpg") as tmp: + tmp.write(uploaded_file.read()) + tmp.flush() + result = subprocess.run( + ["tesseract", tmp.name, "stdout", "--psm", "6"], + check=False, + capture_output=True, + text=True, + timeout=8, + ) + return " ".join(result.stdout.split())[:1200] + except Exception: + return "" diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..4105551 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.7 on 2026-06-04 16:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PropertyEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source', models.CharField(choices=[('current_location', 'Current location'), ('photo', 'Photo upload')], max_length=32)), + ('address', models.CharField(blank=True, max_length=255)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('phone', models.CharField(blank=True, max_length=40)), + ('email', models.EmailField(blank=True, max_length=254)), + ('listing_type', models.CharField(choices=[('sale', 'For sale'), ('rental', 'For rent'), ('unknown', 'Not sure')], default='unknown', max_length=20)), + ('photo', models.ImageField(blank=True, upload_to='properties/%Y/%m/')), + ('extracted_text', models.TextField(blank=True)), + ('has_gps_data', models.BooleanField(default=False)), + ('idealista_url', models.URLField(blank=True)), + ('is_flagged', models.BooleanField(default=False)), + ('flag_count', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name_plural': 'property entries', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PropertyFlag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reason', models.CharField(max_length=160)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('property_entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flags', to='core.propertyentry')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PropertySuggestion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.CharField(blank=True, max_length=255)), + ('phone', models.CharField(blank=True, max_length=40)), + ('email', models.EmailField(blank=True, max_length=254)), + ('listing_type', models.CharField(blank=True, choices=[('sale', 'For sale'), ('rental', 'For rent'), ('unknown', 'Not sure')], max_length=20)), + ('note', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('property_entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='suggestions', to='core.propertyentry')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc7e89bf40562c79ee2c464b103b8fff4adfb1af GIT binary patch literal 4027 zcmcInOK{u983w?INQ#m{S+*?dK|LK)azx3J+}5(K>TM;q;>6F;wNZ$>f*^wc3xJBL z$-##nddw|4r4Bx{i z?EqN(mcz_%T0nf-r*LV%z~R6~u(kjR$OkhG8=?9Pk9g!qfnxBbAL4+YcdZGifoX) zGOfmHz|?cw30mh4+fq2x{#PEwQAe@!aF3uiT}(H6z+!hXaV+B=rUhv6F37d_B@eZO zpLc4GG+=d&g#e)LvrPmiJb`jr%ouobQ`(`XX|x0e5-b*-gTR9n???sc40xc&4MeaXwemx=aYUnD(7uO z%9vQTF>)Jorka(_g7g#@+^~gBRo2uk>_#`SxoTKg(&a36gO-suGZ@ZoF_SkJ+&Fo3gp%e+t`ZUrnjDaPBUb**8{KvSwm*sP&3$qM}YiH(y*@B>u!UL zkclm8?_Zu$U{#a#r{-xGxgiZ4*?ENb=z+gScS+K3Qs}zP2xlOCP0DF<-6T>K)wW!} zp4T)ttQk5CVKoa%$9os(!T`?7s_QGFeGsDGk~O>)S~Sca%E3&K z<}7R89OY>udHtzwe5G%NRt&qx%A4361bcgQ%O!m#-Gy!g!fSF~vsor4J)*W^Ij=J+ zwo-#%rUtXAq~K)}_6jB0hTDvv*`}O<4oNn!N#4_luFICB4pxCqDW%h@_hPr11vOJbrowRdCm<<%$6j%5lMpgmAr<5k4n;? z^0G#~z^FmhRU7t?8$~#Wb%gZ{RLi69Nsy57gc$vOtkq<(IRvf~yPe0xqfxW(`BbvhIyS_rAF4ajmxv3j*52%esvfTs7Y` z50$yAW~+80fO4!V`lqn}gJ?t66~h(KsvBaZgbKM0b2E45X66^%2t84}ceYpDTgZX? zc{;UjWN|9L3JA-m@H3o&jLg(mhWXUW!In(XPNm+x>!n6VN5`aUokns6lWu87=;O({ z3zd0Z|NPY4@_eb{=Uao%Z&pHFIPsT=6Y4IsNLnJgN!qMZ zo8`1wM6}DXcG5Atec9<4CXZ#s!fyOSg_Q%cM@i5Vi! zGQkxpxbk|8Bo<1E1t;Mp4J*c}VElC}Ni3EUi%w#Zh)ZmRC)mA960@bmtdp1};#{RS z*4kV-&t14u?i$+utP=2@X|8aTB+rE-Z#W?PlvFOg!%5;paI#~Oj;T_|l+!Uq#H$D5 zI2s@nZGskUVt4b0f;0V@GyTw+{v$Cqq2gcjw`t)AKBuh!3atQ2Tk(ZZ2?rp9W-ivT z{TLGP2@rd%k;V>WpOVVCcd-w?N9?}<*#8r5yMBmyiC>|iAHF>j9@ELbCk$*)7}%b$ z3?%?t8);8&pLN=kJATqWS!$nj+9!#4`G2V9*WhGUxKAw)JYdy)z^eIx`Fbc+VqvJ} z*+bQAKSiqvWS^4CE&g&#@cAe7V}4mXW|Bd--8*iw^(tkhJDv*kg=lM#I<9i9$<9*BA1#;5261>I-E8KDN RO~erg-cZ5*2~rlrzX26HJVF2f literal 0 HcmV?d00001 diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc index 799581567d2858c54584d0675ede42b2d728f5cb..f4a0b7d2a7752081f8332d9c89a73e463bfe8316 100644 GIT binary patch delta 20 acmZ3%xPp;qIWI340}#Afu9!8EXAS@~ngxLX delta 20 acmZ3%xPp;qIWI340}#~ttjL_mGY0@N6$L&3 diff --git a/core/models.py b/core/models.py index 71a8362..88e8c7f 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,73 @@ +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.urls import reverse -# Create your models here. + +class PropertyEntry(models.Model): + class ListingType(models.TextChoices): + SALE = "sale", "For sale" + RENTAL = "rental", "For rent" + UNKNOWN = "unknown", "Not sure" + + class Source(models.TextChoices): + CURRENT_LOCATION = "current_location", "Current location" + PHOTO = "photo", "Photo upload" + + source = models.CharField(max_length=32, choices=Source.choices) + address = models.CharField(max_length=255, blank=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + phone = models.CharField(max_length=40, blank=True) + email = models.EmailField(blank=True) + listing_type = models.CharField(max_length=20, choices=ListingType.choices, default=ListingType.UNKNOWN) + photo = models.ImageField(upload_to="properties/%Y/%m/", blank=True) + extracted_text = models.TextField(blank=True) + has_gps_data = models.BooleanField(default=False) + idealista_url = models.URLField(blank=True) + is_flagged = models.BooleanField(default=False) + flag_count = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + verbose_name_plural = "property entries" + + def __str__(self): + label = self.address or f"{self.get_source_display()} #{self.pk}" + return f"{label} ({self.get_listing_type_display()})" + + def get_absolute_url(self): + return reverse("property_detail", args=[self.pk]) + + @property + def has_location(self): + return self.latitude is not None and self.longitude is not None + + +class PropertySuggestion(models.Model): + property_entry = models.ForeignKey(PropertyEntry, on_delete=models.CASCADE, related_name="suggestions") + address = models.CharField(max_length=255, blank=True) + phone = models.CharField(max_length=40, blank=True) + email = models.EmailField(blank=True) + listing_type = models.CharField(max_length=20, choices=PropertyEntry.ListingType.choices, blank=True) + note = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"Suggestion for #{self.property_entry_id}" + + +class PropertyFlag(models.Model): + property_entry = models.ForeignKey(PropertyEntry, on_delete=models.CASCADE, related_name="flags") + reason = models.CharField(max_length=160) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"Flag for #{self.property_entry_id}: {self.reason}" diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..30b2e8b 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,11 +1,13 @@ +{% load static %} - {% block title %}Knowledge Base{% endblock %} + + {% block title %}{{ page_title|default:"NearbyNest" }}{% endblock %} + {% if project_description %} - {% endif %} @@ -13,13 +15,57 @@ {% endif %} - {% load static %} + + + + {% block head %}{% endblock %} + + + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} + +
+
+ NearbyNest MVP · Public property sightings + Built for PWA now, mobile app later +
+
+ + + + {% block scripts %}{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..6cbc919 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,101 @@ {% extends "base.html" %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} - {% block content %}
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+
+
+

Mobile-first PWA · property pinboard

+

Spot a sale or rental nearby. Pin it in seconds.

+

NearbyNest lets anyone add a public property sighting from their current location or an uploaded photo, then browse fresh entries by distance or recency.

+ +
+ LocationNotificationsPhotos +
+
+
+
+
+
+
+
+
+
+
+ Live MVP +

Public nearby list

+

{{ total_entries }} entries · {{ photo_entries }} photo uploads

+ Add current location → +
+
+
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
+ + +
+
+
+
+
+ 📍 +

Pin by location

+

Grant browser location permission, then submit an address or GPS coordinates with optional contact details.

+ Pin a property +
+
+
+
+ 📷 +

Upload photo

+

Save only a compressed small image, check EXIF GPS, and attempt local OCR when a system OCR tool exists.

+ Upload photo +
+
+
+
+ 🧭 +

Browse & improve

+

Sort the public list by recency or distance, flag questionable entries, or suggest missing details.

+ View public list +
+
+
+
+
+ +
+
+
+
+

Latest sightings

+

Fresh public property pins

+
+ See all +
+ {% if recent_entries %} +
+ {% for entry in recent_entries %} +
+ {% include "core/partials/property_card.html" with entry=entry %} +
+ {% endfor %} +
+ {% else %} +
+

No properties yet

+

Be the first to add a current-location pin or photo upload.

+ Create first pin +
+ {% endif %} +
+
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/core/templates/core/onboarding.html b/core/templates/core/onboarding.html new file mode 100644 index 0000000..aee7607 --- /dev/null +++ b/core/templates/core/onboarding.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Permission onboarding

+

Prepare your device for fast property pins.

+

These browser prompts mimic the future mobile app permissions: location, notifications, and photo access where supported.

+
+
+
+
📍

Location

Used to add a property at your current position and sort entries by distance.

+ + Not requested yet +
+
+
🔔

Notifications

Future-ready prompt for review alerts and nearby updates.

+ + Not requested yet +
+
+
🖼️

Photos

Web browsers ask when you choose a file; mobile apps can request photo library permission directly.

+ Choose a photo + Requested by the file picker on supported platforms +
+
+ +
+
+{% endblock %} diff --git a/core/templates/core/partials/property_card.html b/core/templates/core/partials/property_card.html new file mode 100644 index 0000000..e9db53d --- /dev/null +++ b/core/templates/core/partials/property_card.html @@ -0,0 +1,24 @@ +
+ {% if entry.photo %} + Uploaded property photo for entry {{ entry.pk }} + {% else %} + + {% endif %} +
+
+ {{ entry.get_listing_type_display }} + {{ entry.created_at|timesince }} ago +
+

{{ entry.address|default:"Location-only property" }}

+

+ {{ entry.get_source_display }}{% if entry.has_gps_data %} · GPS from photo{% endif %}{% if entry.distance_km is not None %} · {{ entry.distance_km|floatformat:1 }} km{% endif %} +

+
+ {% if entry.phone %}Phone{% endif %} + {% if entry.email %}Email{% endif %} + {% if entry.idealista_url %}Idealista link{% endif %} + {% if not entry.phone and not entry.email %}Needs details{% endif %} +
+ +
+
diff --git a/core/templates/core/property_detail.html b/core/templates/core/property_detail.html new file mode 100644 index 0000000..835a4c4 --- /dev/null +++ b/core/templates/core/property_detail.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+
+ {% if entry.photo %} + Uploaded property photo for entry {{ entry.pk }} + {% endif %} +
+ {{ entry.get_listing_type_display }} +

{{ entry.address|default:"Location-only property" }}

+

Added {{ entry.created_at|date:"M j, Y H:i" }} · {{ entry.get_source_display }}

+
+
Phone
{{ entry.phone|default:"Missing" }}
+
Email
{{ entry.email|default:"Missing" }}
+
GPS
{% if entry.has_location %}{{ entry.latitude }}, {{ entry.longitude }}{% else %}Missing{% endif %}
+
Photo GPS
{{ entry.has_gps_data|yesno:"Detected,Not detected" }}
+
+ {% if entry.extracted_text %} +

Text spotted in photo

{{ entry.extracted_text }}

+ {% endif %} + {% if entry.idealista_url %} + Open best-effort Idealista search + {% endif %} +
+
+
+
+ +
+
+
+
+{% endblock %} diff --git a/core/templates/core/property_form_location.html b/core/templates/core/property_form_location.html new file mode 100644 index 0000000..02696d7 --- /dev/null +++ b/core/templates/core/property_form_location.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Create from current location

+

Add a property pin

+

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

+ + Location not captured yet. +
+ {% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + {{ form.latitude }}{{ form.longitude }} + {% for field in form.visible_fields %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ + Cancel +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/property_form_photo.html b/core/templates/core/property_form_photo.html new file mode 100644 index 0000000..3a0c29e --- /dev/null +++ b/core/templates/core/property_form_photo.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Create from photo

+

Upload a property photo

+

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

+
+ {% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + {% for field in form.visible_fields %} +
+ + {{ field }} + {% if field.name == 'photo' %}
Originals are discarded after compression; max upload 12MB.
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ + Cancel +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/property_list.html b/core/templates/core/property_list.html new file mode 100644 index 0000000..bc2d2ea --- /dev/null +++ b/core/templates/core/property_list.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Public list

+

Nearby property sightings

+

Sort by newest posts or allow location to estimate the closest entries.

+
+ +
+ +
+ + +
+ + +
+ + + +
+ + {% if entries %} +
+ {% for entry in entries %} +
+ {% include "core/partials/property_card.html" with entry=entry %} +
+ {% endfor %} +
+ {% else %} +
+

No visible properties yet

+

Add the first public pin from your location or upload a compressed property photo.

+ Create first entry +
+ {% endif %} +
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 6299e3d..c9e0db4 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,23 @@ from django.urls import path -from .views import home +from .views import ( + add_location_property, + add_photo_property, + flag_property, + home, + onboarding, + property_detail, + property_list, + suggest_property_update, +) urlpatterns = [ path("", home, name="home"), + path("onboarding/", onboarding, name="onboarding"), + path("properties/", property_list, name="property_list"), + path("properties/add/location/", add_location_property, name="add_location_property"), + path("properties/add/photo/", add_photo_property, name="add_photo_property"), + path("properties//", property_detail, name="property_detail"), + path("properties//suggest/", suggest_property_update, name="suggest_property_update"), + path("properties//flag/", flag_property, name="flag_property"), ] diff --git a/core/views.py b/core/views.py index c9aed12..2a31551 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,178 @@ -import os -import platform +import math +from urllib.parse import quote_plus -from django import get_version as django_version -from django.shortcuts import render -from django.utils import timezone +from django.contrib import messages +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse + +from .forms import PropertyFlagForm, PropertyLocationForm, PropertyPhotoForm, PropertySuggestionForm +from .image_tools import compress_image, ocr_text_best_effort +from .models import PropertyEntry + + +def build_idealista_url(entry): + query = entry.address or " ".join(filter(None, [entry.phone, entry.email])) + if not query: + return "" + return f"https://www.idealista.com/en/search/{quote_plus(query)}/" + + +def _distance_km(lat1, lng1, lat2, lng2): + if None in (lat1, lng1, lat2, lng2): + return None + radius = 6371 + phi1, phi2 = math.radians(float(lat1)), math.radians(float(lat2)) + d_phi = math.radians(float(lat2) - float(lat1)) + d_lambda = math.radians(float(lng2) - float(lng1)) + a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 + return radius * (2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))) + + +def _decorate_distances(entries, user_lat, user_lng): + decorated = [] + for entry in entries: + distance = _distance_km(user_lat, user_lng, entry.latitude, entry.longitude) if user_lat and user_lng else None + entry.distance_km = distance + decorated.append(entry) + return decorated def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() - + entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at")[:6]) context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + "page_title": "NearbyNest — mobile property pinboard", + "meta_description": "A mobile-first PWA for posting nearby property sightings by current location or photo.", + "recent_entries": entries, + "total_entries": PropertyEntry.objects.count(), + "photo_entries": PropertyEntry.objects.exclude(photo="").count(), } return render(request, "core/index.html", context) + + +def onboarding(request): + context = { + "page_title": "Onboarding permissions — NearbyNest", + "meta_description": "Grant location, notification, and photo permissions for the mobile property pinboard.", + } + return render(request, "core/onboarding.html", context) + + +def property_list(request): + sort = request.GET.get("sort", "recent") + user_lat = request.GET.get("lat") + user_lng = request.GET.get("lng") + entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at")) + entries = _decorate_distances(entries, user_lat, user_lng) + if sort == "distance" and user_lat and user_lng: + entries.sort(key=lambda entry: entry.distance_km if entry.distance_km is not None else float("inf")) + context = { + "page_title": "Public property list — NearbyNest", + "meta_description": "Browse public property posts by recency or distance from your current location.", + "entries": entries, + "sort": sort, + "user_lat": user_lat or "", + "user_lng": user_lng or "", + } + return render(request, "core/property_list.html", context) + + +def property_detail(request, pk): + entry = get_object_or_404(PropertyEntry.objects.prefetch_related("suggestions", "flags"), pk=pk) + context = { + "page_title": f"Property #{entry.pk} — NearbyNest", + "meta_description": "Review a public property pin, extracted details, community suggestions, and flagging options.", + "entry": entry, + "suggestion_form": PropertySuggestionForm(), + "flag_form": PropertyFlagForm(), + } + return render(request, "core/property_detail.html", context) + + +@transaction.atomic +def add_location_property(request): + if request.method == "POST": + form = PropertyLocationForm(request.POST) + if form.is_valid(): + entry = form.save(commit=False) + entry.source = PropertyEntry.Source.CURRENT_LOCATION + entry.idealista_url = build_idealista_url(entry) + entry.save() + messages.success(request, "Property pinned to the public list.") + return redirect(entry.get_absolute_url()) + else: + form = PropertyLocationForm() + context = { + "page_title": "Add current-location property — NearbyNest", + "meta_description": "Add a property sighting from your current location, with optional contact details.", + "form": form, + } + return render(request, "core/property_form_location.html", context) + + +@transaction.atomic +def add_photo_property(request): + if request.method == "POST": + form = PropertyPhotoForm(request.POST, request.FILES) + if form.is_valid(): + uploaded = form.cleaned_data["photo"] + processed, error = compress_image(uploaded, uploaded.name) + if error: + form.add_error("photo", error) + else: + uploaded.seek(0) + entry = form.save(commit=False) + entry.source = PropertyEntry.Source.PHOTO + entry.photo = processed["file"] + if processed["latitude"] is not None and not entry.latitude: + entry.latitude = processed["latitude"] + entry.longitude = processed["longitude"] + entry.has_gps_data = True + entry.extracted_text = ocr_text_best_effort(uploaded) + entry.idealista_url = build_idealista_url(entry) + entry.save() + messages.success(request, "Photo compressed under 256KB and added to the pinboard.") + return redirect(entry.get_absolute_url()) + else: + form = PropertyPhotoForm() + context = { + "page_title": "Add property photo — NearbyNest", + "meta_description": "Upload a property photo; the MVP compresses it, checks EXIF GPS, and attempts local OCR.", + "form": form, + } + return render(request, "core/property_form_photo.html", context) + + +@transaction.atomic +def suggest_property_update(request, pk): + entry = get_object_or_404(PropertyEntry, pk=pk) + if request.method != "POST": + return redirect(entry.get_absolute_url()) + form = PropertySuggestionForm(request.POST) + if form.is_valid(): + suggestion = form.save(commit=False) + suggestion.property_entry = entry + suggestion.save() + messages.success(request, "Thanks — your suggested details were saved for review.") + else: + messages.error(request, "Please add at least one valid detail before submitting a suggestion.") + return redirect(entry.get_absolute_url()) + + +@transaction.atomic +def flag_property(request, pk): + entry = get_object_or_404(PropertyEntry, pk=pk) + if request.method != "POST": + return redirect(entry.get_absolute_url()) + form = PropertyFlagForm(request.POST) + if form.is_valid(): + flag = form.save(commit=False) + flag.property_entry = entry + flag.save() + entry.flag_count = entry.flags.count() + entry.is_flagged = entry.flag_count >= 3 + entry.save(update_fields=["flag_count", "is_flagged", "updated_at"]) + messages.success(request, "Flag saved. Entries with repeated flags are hidden from the public list.") + else: + messages.error(request, "Add a short reason so reviewers know what to check.") + return redirect(entry.get_absolute_url()) diff --git a/requirements.txt b/requirements.txt index e22994c..6121446 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ Django==5.2.7 mysqlclient==2.2.7 python-dotenv==1.1.1 + +Pillow==9.4.0 diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..4f41501 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,63 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +/* NearbyNest custom brand system */ +:root { + --nest-ink: #14213d; + --nest-muted: #667085; + --nest-bg: #f7f3ea; + --nest-surface: #fffaf0; + --nest-primary: #0f766e; + --nest-primary-dark: #0b4f4a; + --nest-secondary: #ffb703; + --nest-accent: #ef476f; + --nest-mint: #d7fff1; + --nest-border: rgba(20, 33, 61, 0.12); + --nest-shadow: 0 24px 70px rgba(20, 33, 61, 0.14); } +* { box-sizing: border-box; } +body { margin: 0; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--nest-ink); background: radial-gradient(circle at 8% 0%, rgba(255, 183, 3, 0.24), transparent 28rem), radial-gradient(circle at 92% 16%, rgba(15, 118, 110, 0.18), transparent 30rem), var(--nest-bg); min-height: 100vh; } +h1, h2, h3, .brand-mark { font-family: "Plus Jakarta Sans", "Inter", sans-serif; } +a { color: var(--nest-primary); text-decoration: none; } a:hover { color: var(--nest-primary-dark); } +.site-header { background: rgba(247, 243, 234, 0.78); border-bottom: 1px solid var(--nest-border); backdrop-filter: blur(20px); } +.navbar-brand, .nav-link { color: var(--nest-ink); font-weight: 700; } .nav-link:hover, .admin-link { color: var(--nest-primary); } .navbar-toggler { border: 0; } +.brand-mark { display: inline-flex; align-items: center; gap: .55rem; font-weight: 800; letter-spacing: -.03em; } +.brand-icon { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: 1rem; background: linear-gradient(135deg, var(--nest-primary), #20c997); color: white; box-shadow: 0 12px 30px rgba(15, 118, 110, .28); } +.btn { font-weight: 800; border-radius: 999px; padding: .75rem 1.15rem; } +.btn-nest { color: white; background: linear-gradient(135deg, var(--nest-primary), #14b8a6); border: 0; box-shadow: 0 16px 36px rgba(15, 118, 110, .28); } +.btn-nest:hover { color: white; transform: translateY(-1px); box-shadow: 0 20px 44px rgba(15, 118, 110, .34); } +.btn-ghost { color: var(--nest-ink); background: rgba(255,255,255,.62); border: 1px solid rgba(20,33,61,.14); backdrop-filter: blur(14px); } +.btn-ghost:hover { background: white; border-color: rgba(15,118,110,.35); } +.flash-stack { position: fixed; z-index: 1050; top: 5.25rem; left: 0; right: 0; max-width: 720px; } +.hero-section { position: relative; overflow: hidden; padding: clamp(4rem, 8vw, 7.5rem) 0 4rem; } +.hero-title { font-size: clamp(3rem, 8vw, 5.8rem); line-height: .92; letter-spacing: -.075em; margin-bottom: 1.2rem; } +.hero-copy { max-width: 42rem; color: var(--nest-muted); font-size: clamp(1.05rem, 2vw, 1.28rem); } +.eyebrow { text-transform: uppercase; letter-spacing: .16em; color: var(--nest-primary); font-size: .78rem; font-weight: 900; margin-bottom: .8rem; } +.permission-strip { display: flex; flex-wrap: wrap; gap: .65rem; } +.permission-strip span, .mini-tags span { display: inline-flex; align-items: center; border-radius: 999px; padding: .45rem .75rem; background: rgba(255,255,255,.68); border: 1px solid var(--nest-border); font-size: .83rem; font-weight: 800; } +.orb { position: absolute; border-radius: 999px; filter: blur(2px); opacity: .85; pointer-events: none; } .orb-one { width: 8rem; height: 8rem; background: var(--nest-secondary); top: 8rem; right: 12%; } .orb-two { width: 5rem; height: 5rem; background: var(--nest-accent); bottom: 5rem; left: 8%; } +.phone-frame { max-width: 23rem; border-radius: 2.5rem; padding: .9rem; background: #102a43; box-shadow: var(--nest-shadow); transform: rotate(2deg); } +.phone-top { width: 5rem; height: .35rem; border-radius: 999px; background: rgba(255,255,255,.35); margin: 0 auto .8rem; } +.map-card { position: relative; overflow: hidden; min-height: 34rem; border-radius: 2rem; background: linear-gradient(150deg, #d7fff1, #fffaf0 52%, #ffe4ad); } +.map-grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(20,33,61,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(20,33,61,.08) 1px, transparent 1px); background-size: 42px 42px; transform: rotate(-12deg) scale(1.15); } +.pin { position: absolute; width: 1.4rem; height: 1.4rem; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--nest-accent); box-shadow: 0 10px 22px rgba(239,71,111,.35); } +.pin::after { content: ""; position: absolute; inset: .38rem; border-radius: 999px; background: white; } .pin-a { left: 25%; top: 23%; } .pin-b { right: 22%; top: 42%; background: var(--nest-primary); } .pin-c { left: 42%; bottom: 23%; background: var(--nest-secondary); } +.glass-panel { position: absolute; left: 1.1rem; right: 1.1rem; bottom: 1.1rem; padding: 1.2rem; border-radius: 1.5rem; background: rgba(255,255,255,.78); border: 1px solid rgba(255,255,255,.8); backdrop-filter: blur(18px); box-shadow: 0 18px 42px rgba(20,33,61,.14); } .glass-panel h2 { margin: .8rem 0 .25rem; font-size: 1.35rem; } +.section-pad { padding: 4rem 0; } .section-heading h2, .page-heading h1, .list-hero h1 { font-size: clamp(2rem, 4vw, 3.4rem); letter-spacing: -.055em; } +.feature-card, .property-card, .form-card, .detail-panel, .activity-card, .sort-panel, .permission-wizard { background: rgba(255,250,240,.86); border: 1px solid var(--nest-border); border-radius: 2rem; box-shadow: 0 18px 54px rgba(20,33,61,.08); } +.feature-card { padding: 1.5rem; } .feature-card h2 { font-size: 1.35rem; margin-top: 1rem; } .feature-card p, .text-muted, .card-meta { color: var(--nest-muted) !important; } .feature-icon, .step-icon { font-size: 2rem; } +.property-card { position: relative; overflow: hidden; transition: transform .2s ease, box-shadow .2s ease; } .property-card:hover { transform: translateY(-4px); box-shadow: var(--nest-shadow); } +.property-thumb { width: 100%; height: 13rem; object-fit: cover; background: var(--nest-mint); } .placeholder-thumb { display: grid; place-items: center; font-size: 4rem; color: var(--nest-primary); } +.property-card-body { padding: 1.15rem; } .property-card h3 { font-size: 1.2rem; margin: .8rem 0 .25rem; } .listing-badge { background: #14213d; color: white; } +.mini-tags { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .9rem; } .mini-tags span { padding: .28rem .55rem; font-size: .72rem; } +.empty-state { text-align: center; padding: 3rem 1.5rem; border: 1px dashed rgba(20,33,61,.24); border-radius: 2rem; background: rgba(255,255,255,.5); } +.site-footer { margin-top: 4rem; padding: 2rem 0; color: var(--nest-muted); border-top: 1px solid var(--nest-border); } +.page-shell { padding: 3rem 0 1rem; } .narrow-container { max-width: 780px; } .page-heading { margin-bottom: 2rem; } .page-heading p { color: var(--nest-muted); } +.permission-wizard { display: grid; gap: 1rem; padding: 1rem; } .permission-step { display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: center; padding: 1.2rem; border-radius: 1.4rem; background: rgba(255,255,255,.56); } +.permission-step h2 { font-size: 1.25rem; margin: .3rem 0; } .status-text { color: var(--nest-muted); font-weight: 700; } .action-dock { display: flex; flex-wrap: wrap; justify-content: center; gap: .8rem; margin-top: 1.4rem; } +.form-card { padding: clamp(1.2rem, 4vw, 2rem); } .form-card h1 { letter-spacing: -.055em; } +.form-control, .form-select { border-radius: 1rem; border-color: rgba(20,33,61,.16); padding: .82rem 1rem; } .form-control:focus, .form-select:focus { border-color: var(--nest-primary); box-shadow: 0 0 0 .25rem rgba(15,118,110,.12); } +.invalid-copy { color: #b42318; font-weight: 700; font-size: .9rem; margin-top: .35rem; } +.list-hero { display: flex; justify-content: space-between; align-items: end; gap: 1.5rem; margin-bottom: 1.25rem; } .sort-panel { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; padding: 1rem; margin-bottom: 1rem; } .sort-panel > div { min-width: min(100%, 16rem); } +.detail-panel { overflow: hidden; } .detail-photo { width: 100%; max-height: 28rem; object-fit: cover; } .detail-body { padding: clamp(1.3rem, 4vw, 2rem); } .detail-body h1 { margin-top: 1rem; letter-spacing: -.055em; } +.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: .9rem; margin: 1.5rem 0; } .detail-grid div, .analysis-box { padding: 1rem; border-radius: 1.2rem; background: rgba(255,255,255,.62); border: 1px solid var(--nest-border); } +.detail-grid dt { color: var(--nest-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .1em; } .detail-grid dd { margin: .2rem 0 0; font-weight: 800; word-break: break-word; } +.analysis-box h2, .compact-card h2, .activity-card h2 { font-size: 1.2rem; } .side-stack { display: grid; gap: 1rem; } .compact-card { border-radius: 1.5rem; } .danger-soft { background: rgba(255, 245, 245, .88); } .activity-card { padding: 1.3rem; } +@media (max-width: 767px) { .hero-section { padding-top: 3rem; } .phone-frame { transform: none; } .map-card { min-height: 27rem; } .permission-step, .list-hero { display: block; } .permission-step .btn { width: 100%; margin: .75rem 0 .35rem; } .detail-grid { grid-template-columns: 1fr; } } diff --git a/static/js/pinboard.js b/static/js/pinboard.js new file mode 100644 index 0000000..aa801da --- /dev/null +++ b/static/js/pinboard.js @@ -0,0 +1,56 @@ +function setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} + +function requestLocation(callback, statusId) { + if (!navigator.geolocation) { + setText(statusId, "Location is not supported by this browser."); + return; + } + setText(statusId, "Requesting location…"); + navigator.geolocation.getCurrentPosition( + (pos) => { + const lat = pos.coords.latitude.toFixed(6); + const lng = pos.coords.longitude.toFixed(6); + setText(statusId, `Captured ${lat}, ${lng}`); + callback(lat, lng); + }, + () => setText(statusId, "Location permission was denied or unavailable."), + { enableHighAccuracy: true, timeout: 10000 } + ); +} + +document.addEventListener("click", (event) => { + const action = event.target?.dataset?.action; + if (action === "request-location") { + requestLocation(() => {}, "location-status"); + } + if (action === "request-notifications") { + if (!window.Notification) { + setText("notification-status", "Notifications are not supported here."); + return; + } + Notification.requestPermission().then((permission) => { + setText("notification-status", `Notification permission: ${permission}`); + }); + } + if (action === "fill-current-location") { + requestLocation((lat, lng) => { + const latInput = document.getElementById("id_latitude"); + const lngInput = document.getElementById("id_longitude"); + if (latInput) latInput.value = lat; + if (lngInput) lngInput.value = lng; + }, "form-location-status"); + } + if (action === "use-location-for-list") { + requestLocation((lat, lng) => { + document.querySelector("[data-user-lat]").value = lat; + document.querySelector("[data-user-lng]").value = lng; + const sort = document.getElementById("sort"); + if (sort) sort.value = "distance"; + const form = document.querySelector("[data-distance-form]"); + if (form) form.submit(); + }, "list-location-status"); + } +}); diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..4f41501 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,63 @@ - +/* NearbyNest custom brand system */ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); -} -body { - margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - text-align: center; - overflow: hidden; - position: relative; + --nest-ink: #14213d; + --nest-muted: #667085; + --nest-bg: #f7f3ea; + --nest-surface: #fffaf0; + --nest-primary: #0f766e; + --nest-primary-dark: #0b4f4a; + --nest-secondary: #ffb703; + --nest-accent: #ef476f; + --nest-mint: #d7fff1; + --nest-border: rgba(20, 33, 61, 0.12); + --nest-shadow: 0 24px 70px rgba(20, 33, 61, 0.14); } +* { box-sizing: border-box; } +body { margin: 0; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--nest-ink); background: radial-gradient(circle at 8% 0%, rgba(255, 183, 3, 0.24), transparent 28rem), radial-gradient(circle at 92% 16%, rgba(15, 118, 110, 0.18), transparent 30rem), var(--nest-bg); min-height: 100vh; } +h1, h2, h3, .brand-mark { font-family: "Plus Jakarta Sans", "Inter", sans-serif; } +a { color: var(--nest-primary); text-decoration: none; } a:hover { color: var(--nest-primary-dark); } +.site-header { background: rgba(247, 243, 234, 0.78); border-bottom: 1px solid var(--nest-border); backdrop-filter: blur(20px); } +.navbar-brand, .nav-link { color: var(--nest-ink); font-weight: 700; } .nav-link:hover, .admin-link { color: var(--nest-primary); } .navbar-toggler { border: 0; } +.brand-mark { display: inline-flex; align-items: center; gap: .55rem; font-weight: 800; letter-spacing: -.03em; } +.brand-icon { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: 1rem; background: linear-gradient(135deg, var(--nest-primary), #20c997); color: white; box-shadow: 0 12px 30px rgba(15, 118, 110, .28); } +.btn { font-weight: 800; border-radius: 999px; padding: .75rem 1.15rem; } +.btn-nest { color: white; background: linear-gradient(135deg, var(--nest-primary), #14b8a6); border: 0; box-shadow: 0 16px 36px rgba(15, 118, 110, .28); } +.btn-nest:hover { color: white; transform: translateY(-1px); box-shadow: 0 20px 44px rgba(15, 118, 110, .34); } +.btn-ghost { color: var(--nest-ink); background: rgba(255,255,255,.62); border: 1px solid rgba(20,33,61,.14); backdrop-filter: blur(14px); } +.btn-ghost:hover { background: white; border-color: rgba(15,118,110,.35); } +.flash-stack { position: fixed; z-index: 1050; top: 5.25rem; left: 0; right: 0; max-width: 720px; } +.hero-section { position: relative; overflow: hidden; padding: clamp(4rem, 8vw, 7.5rem) 0 4rem; } +.hero-title { font-size: clamp(3rem, 8vw, 5.8rem); line-height: .92; letter-spacing: -.075em; margin-bottom: 1.2rem; } +.hero-copy { max-width: 42rem; color: var(--nest-muted); font-size: clamp(1.05rem, 2vw, 1.28rem); } +.eyebrow { text-transform: uppercase; letter-spacing: .16em; color: var(--nest-primary); font-size: .78rem; font-weight: 900; margin-bottom: .8rem; } +.permission-strip { display: flex; flex-wrap: wrap; gap: .65rem; } +.permission-strip span, .mini-tags span { display: inline-flex; align-items: center; border-radius: 999px; padding: .45rem .75rem; background: rgba(255,255,255,.68); border: 1px solid var(--nest-border); font-size: .83rem; font-weight: 800; } +.orb { position: absolute; border-radius: 999px; filter: blur(2px); opacity: .85; pointer-events: none; } .orb-one { width: 8rem; height: 8rem; background: var(--nest-secondary); top: 8rem; right: 12%; } .orb-two { width: 5rem; height: 5rem; background: var(--nest-accent); bottom: 5rem; left: 8%; } +.phone-frame { max-width: 23rem; border-radius: 2.5rem; padding: .9rem; background: #102a43; box-shadow: var(--nest-shadow); transform: rotate(2deg); } +.phone-top { width: 5rem; height: .35rem; border-radius: 999px; background: rgba(255,255,255,.35); margin: 0 auto .8rem; } +.map-card { position: relative; overflow: hidden; min-height: 34rem; border-radius: 2rem; background: linear-gradient(150deg, #d7fff1, #fffaf0 52%, #ffe4ad); } +.map-grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(20,33,61,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(20,33,61,.08) 1px, transparent 1px); background-size: 42px 42px; transform: rotate(-12deg) scale(1.15); } +.pin { position: absolute; width: 1.4rem; height: 1.4rem; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--nest-accent); box-shadow: 0 10px 22px rgba(239,71,111,.35); } +.pin::after { content: ""; position: absolute; inset: .38rem; border-radius: 999px; background: white; } .pin-a { left: 25%; top: 23%; } .pin-b { right: 22%; top: 42%; background: var(--nest-primary); } .pin-c { left: 42%; bottom: 23%; background: var(--nest-secondary); } +.glass-panel { position: absolute; left: 1.1rem; right: 1.1rem; bottom: 1.1rem; padding: 1.2rem; border-radius: 1.5rem; background: rgba(255,255,255,.78); border: 1px solid rgba(255,255,255,.8); backdrop-filter: blur(18px); box-shadow: 0 18px 42px rgba(20,33,61,.14); } .glass-panel h2 { margin: .8rem 0 .25rem; font-size: 1.35rem; } +.section-pad { padding: 4rem 0; } .section-heading h2, .page-heading h1, .list-hero h1 { font-size: clamp(2rem, 4vw, 3.4rem); letter-spacing: -.055em; } +.feature-card, .property-card, .form-card, .detail-panel, .activity-card, .sort-panel, .permission-wizard { background: rgba(255,250,240,.86); border: 1px solid var(--nest-border); border-radius: 2rem; box-shadow: 0 18px 54px rgba(20,33,61,.08); } +.feature-card { padding: 1.5rem; } .feature-card h2 { font-size: 1.35rem; margin-top: 1rem; } .feature-card p, .text-muted, .card-meta { color: var(--nest-muted) !important; } .feature-icon, .step-icon { font-size: 2rem; } +.property-card { position: relative; overflow: hidden; transition: transform .2s ease, box-shadow .2s ease; } .property-card:hover { transform: translateY(-4px); box-shadow: var(--nest-shadow); } +.property-thumb { width: 100%; height: 13rem; object-fit: cover; background: var(--nest-mint); } .placeholder-thumb { display: grid; place-items: center; font-size: 4rem; color: var(--nest-primary); } +.property-card-body { padding: 1.15rem; } .property-card h3 { font-size: 1.2rem; margin: .8rem 0 .25rem; } .listing-badge { background: #14213d; color: white; } +.mini-tags { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .9rem; } .mini-tags span { padding: .28rem .55rem; font-size: .72rem; } +.empty-state { text-align: center; padding: 3rem 1.5rem; border: 1px dashed rgba(20,33,61,.24); border-radius: 2rem; background: rgba(255,255,255,.5); } +.site-footer { margin-top: 4rem; padding: 2rem 0; color: var(--nest-muted); border-top: 1px solid var(--nest-border); } +.page-shell { padding: 3rem 0 1rem; } .narrow-container { max-width: 780px; } .page-heading { margin-bottom: 2rem; } .page-heading p { color: var(--nest-muted); } +.permission-wizard { display: grid; gap: 1rem; padding: 1rem; } .permission-step { display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: center; padding: 1.2rem; border-radius: 1.4rem; background: rgba(255,255,255,.56); } +.permission-step h2 { font-size: 1.25rem; margin: .3rem 0; } .status-text { color: var(--nest-muted); font-weight: 700; } .action-dock { display: flex; flex-wrap: wrap; justify-content: center; gap: .8rem; margin-top: 1.4rem; } +.form-card { padding: clamp(1.2rem, 4vw, 2rem); } .form-card h1 { letter-spacing: -.055em; } +.form-control, .form-select { border-radius: 1rem; border-color: rgba(20,33,61,.16); padding: .82rem 1rem; } .form-control:focus, .form-select:focus { border-color: var(--nest-primary); box-shadow: 0 0 0 .25rem rgba(15,118,110,.12); } +.invalid-copy { color: #b42318; font-weight: 700; font-size: .9rem; margin-top: .35rem; } +.list-hero { display: flex; justify-content: space-between; align-items: end; gap: 1.5rem; margin-bottom: 1.25rem; } .sort-panel { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; padding: 1rem; margin-bottom: 1rem; } .sort-panel > div { min-width: min(100%, 16rem); } +.detail-panel { overflow: hidden; } .detail-photo { width: 100%; max-height: 28rem; object-fit: cover; } .detail-body { padding: clamp(1.3rem, 4vw, 2rem); } .detail-body h1 { margin-top: 1rem; letter-spacing: -.055em; } +.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: .9rem; margin: 1.5rem 0; } .detail-grid div, .analysis-box { padding: 1rem; border-radius: 1.2rem; background: rgba(255,255,255,.62); border: 1px solid var(--nest-border); } +.detail-grid dt { color: var(--nest-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .1em; } .detail-grid dd { margin: .2rem 0 0; font-weight: 800; word-break: break-word; } +.analysis-box h2, .compact-card h2, .activity-card h2 { font-size: 1.2rem; } .side-stack { display: grid; gap: 1rem; } .compact-card { border-radius: 1.5rem; } .danger-soft { background: rgba(255, 245, 245, .88); } .activity-card { padding: 1.3rem; } +@media (max-width: 767px) { .hero-section { padding-top: 3rem; } .phone-frame { transform: none; } .map-card { min-height: 27rem; } .permission-step, .list-hero { display: block; } .permission-step .btn { width: 100%; margin: .75rem 0 .35rem; } .detail-grid { grid-template-columns: 1fr; } } diff --git a/staticfiles/js/pinboard.js b/staticfiles/js/pinboard.js new file mode 100644 index 0000000..aa801da --- /dev/null +++ b/staticfiles/js/pinboard.js @@ -0,0 +1,56 @@ +function setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} + +function requestLocation(callback, statusId) { + if (!navigator.geolocation) { + setText(statusId, "Location is not supported by this browser."); + return; + } + setText(statusId, "Requesting location…"); + navigator.geolocation.getCurrentPosition( + (pos) => { + const lat = pos.coords.latitude.toFixed(6); + const lng = pos.coords.longitude.toFixed(6); + setText(statusId, `Captured ${lat}, ${lng}`); + callback(lat, lng); + }, + () => setText(statusId, "Location permission was denied or unavailable."), + { enableHighAccuracy: true, timeout: 10000 } + ); +} + +document.addEventListener("click", (event) => { + const action = event.target?.dataset?.action; + if (action === "request-location") { + requestLocation(() => {}, "location-status"); + } + if (action === "request-notifications") { + if (!window.Notification) { + setText("notification-status", "Notifications are not supported here."); + return; + } + Notification.requestPermission().then((permission) => { + setText("notification-status", `Notification permission: ${permission}`); + }); + } + if (action === "fill-current-location") { + requestLocation((lat, lng) => { + const latInput = document.getElementById("id_latitude"); + const lngInput = document.getElementById("id_longitude"); + if (latInput) latInput.value = lat; + if (lngInput) lngInput.value = lng; + }, "form-location-status"); + } + if (action === "use-location-for-list") { + requestLocation((lat, lng) => { + document.querySelector("[data-user-lat]").value = lat; + document.querySelector("[data-user-lng]").value = lng; + const sort = document.getElementById("sort"); + if (sort) sort.value = "distance"; + const form = document.querySelector("[data-distance-form]"); + if (form) form.submit(); + }, "list-location-status"); + } +});