release: 1.7.86
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run

This commit is contained in:
SkateSpots 2026-11-02 16:19:19 +07:00
parent c30ac8376b
commit a696ba59a4
8 changed files with 83 additions and 57 deletions

View file

@ -1288,6 +1288,11 @@ session with **no other memory of this project**. So:
removes both `skatespots:last-location` and `skatespots:map-view` on removes both `skatespots:last-location` and `skatespots:map-view` on
account switch/logout and is exposed in Settings → Appearance; drafts are account switch/logout and is exposed in Settings → Appearance; drafts are
scoped by pubkey and `useFormDraft` debounces localStorage writes (400 ms). scoped by pubkey and `useFormDraft` debounces localStorage writes (400 ms).
**Coordinates are mandatory and explicit (2026-11-02):** Add Spot now always
shows `Coordinates *` with latitude/longitude inputs (`font-mono text-base`,
`required`, `aria-invalid`), below the pin summary/map; Edit Spot shows the same
larger fields and an inline `coordError`. Both still hard-block publish when
the pair is missing or out of range and always write `lat`/`lon`/`g` tags.
⚠️ **Remaining parity gap:** `EditSpotPage` still uses raw lat/lng + toast ⚠️ **Remaining parity gap:** `EditSpotPage` still uses raw lat/lng + toast
validation and does not yet share the map picker/draft/duplicate guard. validation and does not yet share the map picker/draft/duplicate guard.
- **Map Add entry points (2026-11-02):** every map Add Spot link now carries the - **Map Add entry points (2026-11-02):** every map Add Spot link now carries the

View file

@ -7,10 +7,10 @@ account is required.
- Forge: <https://git.skate-spots.org/skatespots/SkateSpots> - Forge: <https://git.skate-spots.org/skatespots/SkateSpots>
- Latest release: <https://git.skate-spots.org/skatespots/SkateSpots/releases/latest> - Latest release: <https://git.skate-spots.org/skatespots/SkateSpots/releases/latest>
- Signed APK (1.7.85): - Signed APK (1.7.86):
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.85/SkateSpots-1.7.85.apk> <https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.86/SkateSpots-1.7.86.apk>
- SHA-256: - SHA-256:
`58d642b792e6382b2a01159d66155c9cf60970b1cd9c5710200685e40b2a0c95` `7890f29072f7df39241d99df94e9d17050752b22045579dee4190bbe005f0f0d`
The APK is signed with the same key as the Zapstore build. Installing an The APK is signed with the same key as the Zapstore build. Installing an
F-Droid-signed build over it would require uninstalling SkateSpots and losing F-Droid-signed build over it would require uninstalling SkateSpots and losing

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"version": "1.7.85", "version": "1.7.86",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "skate-spots", "name": "skate-spots",
"version": "1.7.85", "version": "1.7.86",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0", "@bitcoinerlab/secp256k1": "^1.2.0",

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.7.85", "version": "1.7.86",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"homepage": "https://skate-spots.org", "homepage": "https://skate-spots.org",

View file

@ -98,10 +98,10 @@
<a <a
class="download" class="download"
href="https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.85/SkateSpots-1.7.85.apk" href="https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.86/SkateSpots-1.7.86.apk"
rel="noopener" rel="noopener"
> >
<span>Download SkateSpots-1.7.85.apk · 60 MB</span> <span>Download SkateSpots-1.7.86.apk · 60 MB</span>
<span aria-hidden="true">↓</span> <span aria-hidden="true">↓</span>
</a> </a>

View file

@ -1,3 +1,11 @@
🛹 Spot coordinates are explicit and mandatory
- Add Spot now shows a permanent "Coordinates *" row with latitude and longitude fields, even when the map is collapsed.
- Edit Spot uses the same larger monospace coordinate fields, with an inline error if the pair is missing or invalid.
- Neither form can publish or save without a valid latitude/longitude pair; the event always carries lat, lon and a geohash.
- The pin summary shows the exact public coordinates in a larger, easier-to-check font.
---
🛹 Navigation: map first, voice in your language 🛹 Navigation: map first, voice in your language
- The Directions map now takes most of the phone screen: 56vh normally and 60vh while following. Route details and transit itineraries stay in the scrollable section below. - The Directions map now takes most of the phone screen: 56vh normally and 60vh while following. Route details and transit itineraries stay in the scrollable section below.
- Voice guidance follows the app language instead of the phone/OS language, and prefers an installed local voice for that language. This covers all 24 app locales; the system default voice is used only when a matching one is not installed. - Voice guidance follows the app language instead of the phone/OS language, and prefers an installed local voice for that language. This covers all 24 app locales; the system default voice is used only when a matching one is not installed.

View file

@ -136,7 +136,6 @@ export default function CreateSpotPage() {
const [errors, setErrors] = useState<FieldErrors>({}); const [errors, setErrors] = useState<FieldErrors>({});
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const [showLocationMap, setShowLocationMap] = useState(!initial.pin); const [showLocationMap, setShowLocationMap] = useState(!initial.pin);
const [showManualCoords, setShowManualCoords] = useState(false);
// Duplicate guard: warn when a spot already exists right where you're pinning // Duplicate guard: warn when a spot already exists right where you're pinning
// one. This is the cheapest fix for clone spam and it points the contributor // one. This is the cheapest fix for clone spam and it points the contributor
// at the existing pin instead. // at the existing pin instead.
@ -663,6 +662,9 @@ export default function CreateSpotPage() {
<p className="text-sm font-bold text-white"> <p className="text-sm font-bold text-white">
{location.trim() || `${lat.toFixed(5)}, ${lng.toFixed(5)}`} {location.trim() || `${lat.toFixed(5)}, ${lng.toFixed(5)}`}
</p> </p>
<p className="font-mono text-base font-black tabular-nums text-white">
{lat.toFixed(6)}, {lng.toFixed(6)}
</p>
<p className="text-[11px] text-gray-400"> <p className="text-[11px] text-gray-400">
{t('spotUi.exactPinPublic')} {t('spotUi.exactPinPublic')}
</p> </p>
@ -717,37 +719,37 @@ export default function CreateSpotPage() {
<p className="text-[11px] leading-snug text-amber-300/90"> <p className="text-[11px] leading-snug text-amber-300/90">
{t('spotUi.exactPinPublic')} {t('spotUi.exactPinPublic')}
</p> </p>
<Collapsible open={showManualCoords} onOpenChange={setShowManualCoords}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-xs font-bold text-gray-400 hover:text-white transition-colors"
>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', showManualCoords && 'rotate-180')} />
{t('createSpot.manualCoords')}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="grid grid-cols-2 gap-4">
<Input
placeholder={t('createSpot.latitudePlaceholder')}
value={latitude}
onChange={(e) => { setLatitude(e.target.value); clearError('location'); }}
inputMode="decimal"
/>
<Input
placeholder={t('createSpot.longitudePlaceholder')}
value={longitude}
onChange={(e) => { setLongitude(e.target.value); clearError('location'); }}
inputMode="decimal"
/>
</div>
</CollapsibleContent>
</Collapsible>
</> </>
)} )}
{/* Coordinates are deliberately visible and editable on BOTH
add and edit. A spot cannot be published without a valid pair. */}
<div className="space-y-2">
<Label className="text-base font-bold">{t('createSpot.coordinates')} *</Label>
<div className="grid grid-cols-2 gap-3">
<Input
aria-label={t('createSpot.latitudePlaceholder')}
placeholder={t('createSpot.latitudePlaceholder')}
value={latitude}
onChange={(e) => { setLatitude(e.target.value); clearError('location'); }}
inputMode="decimal"
required
aria-invalid={!hasCoords}
className="font-mono text-base"
/>
<Input
aria-label={t('createSpot.longitudePlaceholder')}
placeholder={t('createSpot.longitudePlaceholder')}
value={longitude}
onChange={(e) => { setLongitude(e.target.value); clearError('location'); }}
inputMode="decimal"
required
aria-invalid={!hasCoords}
className="font-mono text-base"
/>
</div>
</div>
{errors.location && <p className="text-xs font-medium text-red-400">{errors.location}</p>} {errors.location && <p className="text-xs font-medium text-red-400">{errors.location}</p>}
</section> </section>

View file

@ -83,6 +83,7 @@ export default function EditSpotPage() {
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER); const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
const [contactInfo, setContactInfo] = useState(''); const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState<string[]>([]); const [images, setImages] = useState<string[]>([]);
const [coordError, setCoordError] = useState<string | null>(null);
const [isGettingLocation, setIsGettingLocation] = useState(false); const [isGettingLocation, setIsGettingLocation] = useState(false);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
@ -115,6 +116,7 @@ export default function EditSpotPage() {
setLongitude(lng.toFixed(6)); setLongitude(lng.toFixed(6));
} }
setCoordError(null);
setInitialized(true); setInitialized(true);
} }
}, [spot, initialized]); }, [spot, initialized]);
@ -138,6 +140,7 @@ export default function EditSpotPage() {
(position) => { (position) => {
setLatitude(position.coords.latitude.toFixed(6)); setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setCoordError(null);
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ toast({
title: t('spotUi.locationFound'), title: t('spotUi.locationFound'),
@ -202,7 +205,8 @@ export default function EditSpotPage() {
return; return;
} }
if (!latitude || !longitude) { if (!latitude.trim() || !longitude.trim()) {
setCoordError(t('spotUi.locationRequiredDesc'));
toast({ toast({
title: t('spotUi.locationRequired'), title: t('spotUi.locationRequired'),
description: t('spotUi.locationRequiredDesc'), description: t('spotUi.locationRequiredDesc'),
@ -215,6 +219,7 @@ export default function EditSpotPage() {
const lng = parseFloat(longitude); const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
setCoordError(t('spotUi.invalidCoordinatesDesc'));
toast({ toast({
title: t('spotUi.invalidCoordinates'), title: t('spotUi.invalidCoordinates'),
description: t('spotUi.invalidCoordinatesDesc'), description: t('spotUi.invalidCoordinatesDesc'),
@ -222,6 +227,7 @@ export default function EditSpotPage() {
}); });
return; return;
} }
setCoordError(null);
if (!selectedCategory) { if (!selectedCategory) {
toast({ toast({
@ -417,27 +423,32 @@ export default function EditSpotPage() {
/> />
</div> </div>
{/* Coordinates */} {/* Coordinates — mandatory, visible and large enough to verify on a phone. */}
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('createSpot.coordinates')} *</Label> <Label className="text-base font-bold">{t('createSpot.coordinates')} *</Label>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<div> <Input
<Input aria-label={t('createSpot.latitudePlaceholder')}
placeholder={t('createSpot.latitudePlaceholder')} placeholder={t('createSpot.latitudePlaceholder')}
value={latitude} value={latitude}
onChange={(e) => setLatitude(e.target.value)} onChange={(e) => { setLatitude(e.target.value); setCoordError(null); }}
required inputMode="decimal"
/> required
</div> aria-invalid={!!coordError}
<div> className="font-mono text-base"
<Input />
placeholder={t('createSpot.longitudePlaceholder')} <Input
value={longitude} aria-label={t('createSpot.longitudePlaceholder')}
onChange={(e) => setLongitude(e.target.value)} placeholder={t('createSpot.longitudePlaceholder')}
required value={longitude}
/> onChange={(e) => { setLongitude(e.target.value); setCoordError(null); }}
</div> inputMode="decimal"
required
aria-invalid={!!coordError}
className="font-mono text-base"
/>
</div> </div>
{coordError && <p className="text-xs font-medium text-red-400">{coordError}</p>}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"