feat(create-spot): pin map, progressive disclosure, mandatory photo+description, inline errors, draft autosave
This commit is contained in:
parent
24322b3b1c
commit
efd7e9608d
8 changed files with 650 additions and 341 deletions
30
AGENTS.md
30
AGENTS.md
|
|
@ -1098,11 +1098,31 @@ session with **no other memory of this project**. So:
|
||||||
`SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`.
|
`SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`.
|
||||||
⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots
|
⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots
|
||||||
keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims".
|
keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims".
|
||||||
- **Photo is recommended, not required (2026-09-28):** the hard block in
|
- **Photo + description are required again (2026-10-07, reverses 2026-09-28).**
|
||||||
`CreateSpotPage` was removed — it punished an honest mapper with no photo on
|
Owner decision: the create-spot form now requires **≥1 photo AND a non-empty
|
||||||
hand. Publishing with no photo is allowed; the spot stays **Unverified**
|
description** (on top of name/type/location) — the empty husk / one-word test
|
||||||
(`SpotDetailPage` badge), the success toast says so, and the review queue's
|
pin can't publish. ⚠️ This is a **junk filter, not sybil defence**: a bot can
|
||||||
'no photo' flag lets a human look later. The pre-submit checklist still nudges.
|
upload a random image; the real sybil levers remain PoW, WoT weighting and the
|
||||||
|
≥3-distinct-reporter auto-hide. The 2026-09-28 "photo recommended, not required"
|
||||||
|
decision is **superseded** — do not revert. No-photo publishing is gone.
|
||||||
|
- **Add-spot flow overhaul (2026-10-07).** Three parts, one commit:
|
||||||
|
(1) **Map tap no longer jumps** you into the form. `MapPage.handleMapClick`
|
||||||
|
drops a `pendingAddPin` (rendered by `SpotMap`'s `pendingPin` prop) and shows an
|
||||||
|
"Add a spot here?" card; only its **Add spot** button navigates to
|
||||||
|
`/create-spot?lat=&lng=`. (2) **The form has a real pin map** —
|
||||||
|
`src/components/SpotLocationPicker.tsx` (tap to drop, drag to fine-tune, cached
|
||||||
|
tiles). `createSpot.pickOnMap` was an **orphaned key** — the map picker had been
|
||||||
|
intended all along; raw lat/lng now live behind an "Enter coordinates manually"
|
||||||
|
toggle. (3) **Progressive disclosure + inline errors + draft autosave:** required
|
||||||
|
fields (map, name, description, type, photo) are up front; access/contact/shelter/
|
||||||
|
tags/address sit under a **More details** collapsible (access defaults public,
|
||||||
|
shelter defaults outdoor); field errors render inline (no toast per field); and
|
||||||
|
the form autosaves via **`useFormDraft`** (generalized from `useLabDraft`, same
|
||||||
|
resume-banner pattern). ⚠️ Leaflet's base CSS is **CDN-loaded in `index.html`**
|
||||||
|
(`src/main.tsx` just has a comment) — a new Leaflet map needs no CSS import.
|
||||||
|
Still open: the 60 m duplicate check still runs on submit with
|
||||||
|
`useSkateSpots(500)` (move it to pin-time + a targeted geohash query), and
|
||||||
|
publish still blocks on `reverseGeocodeRegions` before signing.
|
||||||
- **Map pin rendering (perf, 2026-09-29):** the spot/tour/event/crew marker
|
- **Map pin rendering (perf, 2026-09-29):** the spot/tour/event/crew marker
|
||||||
effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker
|
effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker
|
||||||
whenever the parent handed it fresh arrays, and added each marker with a
|
whenever the parent handed it fresh arrays, and added each marker with a
|
||||||
|
|
|
||||||
132
src/components/SpotLocationPicker.tsx
Normal file
132
src/components/SpotLocationPicker.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface LatLng {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpotLocationPickerProps {
|
||||||
|
value: LatLng | null;
|
||||||
|
onChange: (coords: LatLng) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback center before the user has a location (NYC, same as the map default). */
|
||||||
|
const DEFAULT_CENTER: [number, number] = [40.7128, -74.006];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small, tappable Leaflet map for choosing a spot's location.
|
||||||
|
*
|
||||||
|
* Tap anywhere to drop the pin, or drag the pin to fine-tune. This replaces the
|
||||||
|
* raw latitude/longitude text boxes as the PRIMARY way to set a location — those
|
||||||
|
* move behind an "enter coordinates manually" toggle for power users. Uses the
|
||||||
|
* same cached tile layer as the main map, so the basemap still draws offline.
|
||||||
|
*/
|
||||||
|
export function SpotLocationPicker({ value, onChange, className }: SpotLocationPickerProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const markerRef = useRef<L.Marker | null>(null);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
// Captured once so the init effect doesn't depend on `value` (which changes
|
||||||
|
// constantly) — the marker-sync effect below handles later updates.
|
||||||
|
const initialValueRef = useRef(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// Create the map once.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el || mapRef.current) return;
|
||||||
|
|
||||||
|
const initial = initialValueRef.current;
|
||||||
|
const center: [number, number] = initial ? [initial.lat, initial.lng] : DEFAULT_CENTER;
|
||||||
|
const map = L.map(el, {
|
||||||
|
center,
|
||||||
|
zoom: initial ? 15 : 12,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '',
|
||||||
|
subdomains: 'abc',
|
||||||
|
minZoom: 2,
|
||||||
|
maxZoom: 19,
|
||||||
|
className: 'skatespots-dark-tiles',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
|
onChangeRef.current({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => map.invalidateSize());
|
||||||
|
ro.observe(el);
|
||||||
|
// Leaflet can mis-measure inside a freshly-laid-out page — nudge it once.
|
||||||
|
const t = window.setTimeout(() => map.invalidateSize(), 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(t);
|
||||||
|
ro.disconnect();
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
markerRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Draw / move the draggable pin to match `value`.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
if (markerRef.current) {
|
||||||
|
markerRef.current.remove();
|
||||||
|
markerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latlng: [number, number] = [value.lat, value.lng];
|
||||||
|
if (markerRef.current) {
|
||||||
|
markerRef.current.setLatLng(latlng);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = L.marker(latlng, {
|
||||||
|
draggable: true,
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html:
|
||||||
|
'<div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-4px)">' +
|
||||||
|
'<div style="width:22px;height:22px;border-radius:50%;background:#ff0040;border:3px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.55)"></div>' +
|
||||||
|
'<div style="width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-top:11px solid #fff;margin-top:-1px"></div>' +
|
||||||
|
'</div>',
|
||||||
|
iconSize: [22, 34],
|
||||||
|
iconAnchor: [11, 34],
|
||||||
|
}),
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
marker.on('dragend', () => {
|
||||||
|
const p = marker.getLatLng();
|
||||||
|
onChangeRef.current({ lat: p.lat, lng: p.lng });
|
||||||
|
});
|
||||||
|
|
||||||
|
markerRef.current = marker;
|
||||||
|
map.setView(latlng, Math.max(map.getZoom(), 15), { animate: true });
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn('h-64 w-full overflow-hidden rounded-xl border-2 border-white/10', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/hooks/useFormDraft.ts
Normal file
67
src/hooks/useFormDraft.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autosave a long form so it isn't lost on navigation or reload.
|
||||||
|
*
|
||||||
|
* - Persists `snapshot` to localStorage under `<prefix><key>` (content-debounced:
|
||||||
|
* the serialized string only changes when the draft actually changes).
|
||||||
|
* - A saved draft is only offered as **resumable** when it *differs* from the
|
||||||
|
* current form state — so merely opening an existing record (whose prefill
|
||||||
|
* matches the saved draft) never shows a spurious "resume" banner, and a
|
||||||
|
* draft can never silently clobber a freshly loaded record.
|
||||||
|
* - Call `clear()` after a successful publish.
|
||||||
|
*
|
||||||
|
* Used by the Lab profile forms (`useLabDraft`) and the create-spot form.
|
||||||
|
*/
|
||||||
|
export function useFormDraft<T>(
|
||||||
|
prefix: string,
|
||||||
|
key: string,
|
||||||
|
snapshot: T,
|
||||||
|
apply: (draft: T) => void,
|
||||||
|
) {
|
||||||
|
const storageKey = prefix + key;
|
||||||
|
const [loaded, setLoaded] = useState<T | null>(null);
|
||||||
|
const firstRun = useRef(true);
|
||||||
|
const serialized = JSON.stringify(snapshot);
|
||||||
|
|
||||||
|
// Load any saved draft once.
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
if (raw) setLoaded(JSON.parse(raw) as T);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed/blocked storage
|
||||||
|
}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
// Persist on change (skip the initial empty paint).
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstRun.current) {
|
||||||
|
firstRun.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, serialized);
|
||||||
|
} catch {
|
||||||
|
// storage may be full/blocked — a lost draft is not fatal
|
||||||
|
}
|
||||||
|
}, [serialized, storageKey]);
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
if (!loaded) return;
|
||||||
|
apply(loaded);
|
||||||
|
setLoaded(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
setLoaded(null);
|
||||||
|
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumable = loaded !== null && JSON.stringify(loaded) !== serialized;
|
||||||
|
return { resumable, resume, discard, clear };
|
||||||
|
}
|
||||||
|
|
@ -1,62 +1,11 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useFormDraft } from './useFormDraft';
|
||||||
|
|
||||||
const PREFIX = 'skatespots:lab-draft:';
|
const PREFIX = 'skatespots:lab-draft:';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autosave a Lab profile form so a long create/edit isn't lost on nav or reload.
|
* Autosave a Lab profile form. Thin wrapper over the shared `useFormDraft` —
|
||||||
*
|
* kept as a named export so the existing Lab call sites don't change.
|
||||||
* - Persists `snapshot` to localStorage (content-debounced: the serialized
|
|
||||||
* string only changes when the draft actually changes).
|
|
||||||
* - A saved draft is only offered as **resumable** when it *differs* from the
|
|
||||||
* current form state — so merely opening an existing profile (whose prefill
|
|
||||||
* matches the saved draft) never shows a spurious "resume" banner, and a
|
|
||||||
* draft can never silently clobber a freshly loaded profile.
|
|
||||||
* - Call `clear()` after a successful publish.
|
|
||||||
*/
|
*/
|
||||||
export function useLabDraft<T>(key: string, snapshot: T, apply: (draft: T) => void) {
|
export function useLabDraft<T>(key: string, snapshot: T, apply: (draft: T) => void) {
|
||||||
const storageKey = PREFIX + key;
|
return useFormDraft(PREFIX, key, snapshot, apply);
|
||||||
const [loaded, setLoaded] = useState<T | null>(null);
|
|
||||||
const firstRun = useRef(true);
|
|
||||||
const serialized = JSON.stringify(snapshot);
|
|
||||||
|
|
||||||
// Load any saved draft once.
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(storageKey);
|
|
||||||
if (raw) setLoaded(JSON.parse(raw) as T);
|
|
||||||
} catch {
|
|
||||||
// ignore malformed/blocked storage
|
|
||||||
}
|
|
||||||
}, [storageKey]);
|
|
||||||
|
|
||||||
// Persist on change (skip the initial empty paint).
|
|
||||||
useEffect(() => {
|
|
||||||
if (firstRun.current) {
|
|
||||||
firstRun.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey, serialized);
|
|
||||||
} catch {
|
|
||||||
// storage may be full/blocked — a lost draft is not fatal
|
|
||||||
}
|
|
||||||
}, [serialized, storageKey]);
|
|
||||||
|
|
||||||
const resume = () => {
|
|
||||||
if (!loaded) return;
|
|
||||||
apply(loaded);
|
|
||||||
setLoaded(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const discard = () => {
|
|
||||||
setLoaded(null);
|
|
||||||
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const resumable = loaded !== null && JSON.stringify(loaded) !== serialized;
|
|
||||||
return { resumable, resume, discard, clear };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -502,6 +502,18 @@ nav: {
|
||||||
useCurrentLocation: 'Use my current location',
|
useCurrentLocation: 'Use my current location',
|
||||||
pickOnMap: 'Pick Location on Map',
|
pickOnMap: 'Pick Location on Map',
|
||||||
loginToAdd: 'You need to log in to add a skate spot.',
|
loginToAdd: 'You need to log in to add a skate spot.',
|
||||||
|
locationLabel: 'Location',
|
||||||
|
tapToPlace: 'Tap the map to place the pin, or drag it to fine-tune.',
|
||||||
|
manualCoords: 'Enter coordinates manually',
|
||||||
|
moreDetails: 'More details (optional)',
|
||||||
|
photoHint: 'a photo is required to publish',
|
||||||
|
nameRequired: 'Please enter a name for this spot.',
|
||||||
|
descriptionRequired: 'Please describe this spot.',
|
||||||
|
photoRequired: 'Please add at least one photo.',
|
||||||
|
draftTitle: 'You have an unsaved draft',
|
||||||
|
draftBody: 'Pick up where you left off, or discard it.',
|
||||||
|
draftResume: 'Resume',
|
||||||
|
draftDiscard: 'Discard draft',
|
||||||
requiredFields: 'Tell us about this spot. All fields marked with * are required.',
|
requiredFields: 'Tell us about this spot. All fields marked with * are required.',
|
||||||
coordinates: 'Coordinates',
|
coordinates: 'Coordinates',
|
||||||
latitudePlaceholder: 'Latitude (e.g., 34.1012)',
|
latitudePlaceholder: 'Latitude (e.g., 34.1012)',
|
||||||
|
|
|
||||||
|
|
@ -502,6 +502,18 @@ nav: {
|
||||||
useCurrentLocation: 'Usar mi ubicación actual',
|
useCurrentLocation: 'Usar mi ubicación actual',
|
||||||
pickOnMap: 'Elegir Ubicación en el Mapa',
|
pickOnMap: 'Elegir Ubicación en el Mapa',
|
||||||
loginToAdd: 'Necesitas iniciar sesión para añadir un spot de skate.',
|
loginToAdd: 'Necesitas iniciar sesión para añadir un spot de skate.',
|
||||||
|
locationLabel: 'Ubicación',
|
||||||
|
tapToPlace: 'Toca el mapa para colocar el pin, o arrástralo para ajustarlo.',
|
||||||
|
manualCoords: 'Introducir coordenadas manualmente',
|
||||||
|
moreDetails: 'Más detalles (opcional)',
|
||||||
|
photoHint: 'se requiere una foto para publicar',
|
||||||
|
nameRequired: 'Ponle un nombre a este spot.',
|
||||||
|
descriptionRequired: 'Describe este spot.',
|
||||||
|
photoRequired: 'Añade al menos una foto.',
|
||||||
|
draftTitle: 'Tienes un borrador sin guardar',
|
||||||
|
draftBody: 'Continúa donde lo dejaste o descártalo.',
|
||||||
|
draftResume: 'Continuar',
|
||||||
|
draftDiscard: 'Descartar borrador',
|
||||||
requiredFields: 'Cuéntanos sobre este spot. Los campos con * son obligatorios.',
|
requiredFields: 'Cuéntanos sobre este spot. Los campos con * son obligatorios.',
|
||||||
coordinates: 'Coordenadas',
|
coordinates: 'Coordenadas',
|
||||||
latitudePlaceholder: 'Latitud (ej., 34.1012)',
|
latitudePlaceholder: 'Latitud (ej., 34.1012)',
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,22 @@ const thBatch5 = {
|
||||||
publishError: 'เผยแพร่อันนั้นไม่สำเร็จ: {{error}} ลองอีกครั้งในอีกสักครู่',
|
publishError: 'เผยแพร่อันนั้นไม่สำเร็จ: {{error}} ลองอีกครั้งในอีกสักครู่',
|
||||||
unknownError: 'ข้อผิดพลาดไม่ทราบสาเหตุ',
|
unknownError: 'ข้อผิดพลาดไม่ทราบสาเหตุ',
|
||||||
},
|
},
|
||||||
|
// Partial: only the keys this form added — the rest of createSpot still
|
||||||
|
// falls back to English until a full translation batch lands.
|
||||||
|
createSpot: {
|
||||||
|
locationLabel: 'ตำแหน่ง',
|
||||||
|
tapToPlace: 'แตะแผนที่เพื่อปักหมุด หรือลากหมุดเพื่อปรับให้ละเอียด',
|
||||||
|
manualCoords: 'กรอกพิกัดเอง',
|
||||||
|
moreDetails: 'รายละเอียดเพิ่มเติม (ไม่บังคับ)',
|
||||||
|
photoHint: 'ต้องมีรูปจึงจะเผยแพร่ได้',
|
||||||
|
nameRequired: 'กรุณาตั้งชื่อสปอตนี้',
|
||||||
|
descriptionRequired: 'กรุณาอธิบายสปอตนี้',
|
||||||
|
photoRequired: 'กรุณาเพิ่มรูปอย่างน้อยหนึ่งรูป',
|
||||||
|
draftTitle: 'คุณมีฉบับร่างที่ยังไม่ได้บันทึก',
|
||||||
|
draftBody: 'ทำต่อจากที่ค้างไว้ หรือลบทิ้ง',
|
||||||
|
draftResume: 'ทำต่อ',
|
||||||
|
draftDiscard: 'ลบฉบับร่าง',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default thBatch5;
|
export default thBatch5;
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
|
import { useFormDraft } from '@/hooks/useFormDraft';
|
||||||
import { calculateDistance } from '@/lib/sortOptions';
|
import { calculateDistance } from '@/lib/sortOptions';
|
||||||
import type { SkateSpot } from '@/lib/types';
|
import type { SkateSpot } from '@/lib/types';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
@ -19,6 +21,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { QualityMeter } from '@/components/QualityMeter';
|
import { QualityMeter } from '@/components/QualityMeter';
|
||||||
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
||||||
import { TagInput } from '@/components/TagInput';
|
import { TagInput } from '@/components/TagInput';
|
||||||
|
import { SpotLocationPicker, type LatLng } from '@/components/SpotLocationPicker';
|
||||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||||
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
||||||
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
||||||
|
|
@ -31,15 +34,43 @@ import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Check,
|
Check,
|
||||||
Camera,
|
Camera,
|
||||||
|
ChevronDown,
|
||||||
|
Crosshair,
|
||||||
|
History,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { CameraCapture } from '@/components/CameraCapture';
|
import { CameraCapture } from '@/components/CameraCapture';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
/** Per-field inline validation messages (no toast for field errors). */
|
||||||
|
type FieldErrors = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
type?: string;
|
||||||
|
photo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shape of the autosaved draft. Keep every field optional on read so a draft
|
||||||
|
* saved before a new field existed can never crash the form. */
|
||||||
|
type SpotDraft = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
location: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
selectedCategory: string;
|
||||||
|
tags: string[];
|
||||||
|
accessType: 'public' | 'private' | 'locals';
|
||||||
|
shelter: ShelterType;
|
||||||
|
contactInfo: string;
|
||||||
|
images: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DRAFT_PREFIX = 'skatespots:spot-draft:';
|
||||||
|
|
||||||
export default function CreateSpotPage() {
|
export default function CreateSpotPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: `${t('createSpot.title')} - SkateSpots`,
|
title: `${t('createSpot.title')} - SkateSpots`,
|
||||||
description: t('createSpot.subtitle'),
|
description: t('createSpot.subtitle'),
|
||||||
|
|
@ -64,6 +95,9 @@ export default function CreateSpotPage() {
|
||||||
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 [errors, setErrors] = useState<FieldErrors>({});
|
||||||
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
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 (five near-identical pins for
|
// one. This is the cheapest fix for clone spam (five near-identical pins for
|
||||||
// the same spot) — and it points the contributor at the existing pin instead.
|
// the same spot) — and it points the contributor at the existing pin instead.
|
||||||
|
|
@ -76,15 +110,51 @@ export default function CreateSpotPage() {
|
||||||
// on this form.
|
// on this form.
|
||||||
const [isUploadingQuickPhoto, setIsUploadingQuickPhoto] = useState(false);
|
const [isUploadingQuickPhoto, setIsUploadingQuickPhoto] = useState(false);
|
||||||
|
|
||||||
// Pre-fill coordinates from URL params (from map click)
|
// Derived coordinates from the (possibly manually typed) strings.
|
||||||
|
const lat = parseFloat(latitude);
|
||||||
|
const lng = parseFloat(longitude);
|
||||||
|
const hasCoords =
|
||||||
|
Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
|
||||||
|
const coords: LatLng | null = hasCoords ? { lat, lng } : null;
|
||||||
|
|
||||||
|
const clearError = (field: keyof FieldErrors) =>
|
||||||
|
setErrors((prev) => (prev[field] ? { ...prev, [field]: undefined } : prev));
|
||||||
|
|
||||||
|
const handlePick = (c: LatLng) => {
|
||||||
|
setLatitude(c.lat.toFixed(6));
|
||||||
|
setLongitude(c.lng.toFixed(6));
|
||||||
|
clearError('location');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Autosave the draft so a long form isn't lost on a back-navigation.
|
||||||
|
const draft = useFormDraft<SpotDraft>(
|
||||||
|
DRAFT_PREFIX,
|
||||||
|
'new',
|
||||||
|
{ title, description, location, latitude, longitude, selectedCategory, tags, accessType, shelter, contactInfo, images },
|
||||||
|
(d) => {
|
||||||
|
setTitle(d.title ?? '');
|
||||||
|
setDescription(d.description ?? '');
|
||||||
|
setLocation(d.location ?? '');
|
||||||
|
setLatitude(d.latitude ?? '');
|
||||||
|
setLongitude(d.longitude ?? '');
|
||||||
|
setSelectedCategory(d.selectedCategory ?? '');
|
||||||
|
setTags(Array.isArray(d.tags) ? d.tags : []);
|
||||||
|
setAccessType(d.accessType ?? 'public');
|
||||||
|
setShelter(d.shelter ?? DEFAULT_SHELTER);
|
||||||
|
setContactInfo(d.contactInfo ?? '');
|
||||||
|
setImages(Array.isArray(d.images) ? d.images : []);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-fill coordinates from URL params (from map tap / quick capture).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lat = searchParams.get('lat');
|
const latParam = searchParams.get('lat');
|
||||||
const lng = searchParams.get('lng');
|
const lngParam = searchParams.get('lng');
|
||||||
if (lat && lng) {
|
if (latParam && lngParam) {
|
||||||
setLatitude(lat);
|
setLatitude(latParam);
|
||||||
setLongitude(lng);
|
setLongitude(lngParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for quick capture image from map page
|
// Check for quick capture image from map page
|
||||||
const quickCapture = searchParams.get('quickCapture');
|
const quickCapture = searchParams.get('quickCapture');
|
||||||
if (quickCapture === 'true') {
|
if (quickCapture === 'true') {
|
||||||
|
|
@ -130,6 +200,7 @@ export default function CreateSpotPage() {
|
||||||
|
|
||||||
const handleCategoryChange = (category: string) => {
|
const handleCategoryChange = (category: string) => {
|
||||||
setSelectedCategory(category);
|
setSelectedCategory(category);
|
||||||
|
clearError('type');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentLocation = () => {
|
const getCurrentLocation = () => {
|
||||||
|
|
@ -148,6 +219,7 @@ export default function CreateSpotPage() {
|
||||||
setLatitude(position.coords.latitude.toFixed(6));
|
setLatitude(position.coords.latitude.toFixed(6));
|
||||||
setLongitude(position.coords.longitude.toFixed(6));
|
setLongitude(position.coords.longitude.toFixed(6));
|
||||||
setIsGettingLocation(false);
|
setIsGettingLocation(false);
|
||||||
|
clearError('location');
|
||||||
toast({
|
toast({
|
||||||
title: t('spotUi.locationFound'),
|
title: t('spotUi.locationFound'),
|
||||||
description: t('spotUi.locationFoundDesc'),
|
description: t('spotUi.locationFoundDesc'),
|
||||||
|
|
@ -179,6 +251,7 @@ export default function CreateSpotPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
|
clearError('photo');
|
||||||
toast({
|
toast({
|
||||||
title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
|
title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
|
||||||
description:
|
description:
|
||||||
|
|
@ -202,6 +275,7 @@ export default function CreateSpotPage() {
|
||||||
if (url) {
|
if (url) {
|
||||||
setImages((prev) => [...prev, url]);
|
setImages((prev) => [...prev, url]);
|
||||||
setShowCamera(false);
|
setShowCamera(false);
|
||||||
|
clearError('photo');
|
||||||
toast({
|
toast({
|
||||||
title: t('spotUi.photoCaptured'),
|
title: t('spotUi.photoCaptured'),
|
||||||
description: t('spotUi.photoCapturedDesc'),
|
description: t('spotUi.photoCapturedDesc'),
|
||||||
|
|
@ -223,44 +297,21 @@ export default function CreateSpotPage() {
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
if (!title.trim()) {
|
// Inline validation — collect every missing required field at once instead
|
||||||
toast({
|
// of firing one toast at a time. Photo + description are mandatory: they are
|
||||||
title: t('spotUi.titleRequired'),
|
// the cheapest defence against test pins and empty husks.
|
||||||
description: t('spotUi.titleRequiredDesc'),
|
const nextErrors: FieldErrors = {};
|
||||||
variant: 'destructive',
|
if (!title.trim()) nextErrors.title = t('createSpot.nameRequired');
|
||||||
});
|
if (!description.trim()) nextErrors.description = t('createSpot.descriptionRequired');
|
||||||
return;
|
if (!hasCoords) nextErrors.location = t('spotUi.locationRequiredDesc');
|
||||||
}
|
if (!selectedCategory) nextErrors.type = t('spotUi.spotTypeRequiredDesc');
|
||||||
|
if (images.length === 0) nextErrors.photo = t('createSpot.photoRequired');
|
||||||
if (!latitude || !longitude) {
|
|
||||||
toast({
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
title: t('spotUi.locationRequired'),
|
setErrors(nextErrors);
|
||||||
description: t('spotUi.locationRequiredDesc'),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lat = parseFloat(latitude);
|
|
||||||
const lng = parseFloat(longitude);
|
|
||||||
|
|
||||||
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
||||||
toast({
|
|
||||||
title: t('spotUi.invalidCoordinates'),
|
|
||||||
description: t('spotUi.invalidCoordinatesDesc'),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedCategory) {
|
|
||||||
toast({
|
|
||||||
title: t('spotUi.spotTypeRequired'),
|
|
||||||
description: t('spotUi.spotTypeRequiredDesc'),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
// Don't publish while the Quick Snap photo is still uploading — otherwise the
|
// Don't publish while the Quick Snap photo is still uploading — otherwise the
|
||||||
// spot would go out without the photo the user just took.
|
// spot would go out without the photo the user just took.
|
||||||
|
|
@ -272,19 +323,6 @@ export default function CreateSpotPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A photo is still NOT required — but a spot needs *something* beyond a
|
|
||||||
// title + type. Require a photo OR a description so the empty husk (a "test"
|
|
||||||
// pin, an airport with one word) can't publish, without blocking an honest
|
|
||||||
// mapper who has no picture on hand but can describe the place.
|
|
||||||
if (images.length === 0 && !description.trim()) {
|
|
||||||
toast({
|
|
||||||
title: t('spotUi.addPhotoOrDescription'),
|
|
||||||
description: t('spotUi.addPhotoOrDescriptionDesc'),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for an existing spot within 60 m and ask before creating a clone.
|
// Look for an existing spot within 60 m and ask before creating a clone.
|
||||||
if (!dupConfirmedRef.current) {
|
if (!dupConfirmedRef.current) {
|
||||||
const nearby = (existingSpots ?? [])
|
const nearby = (existingSpots ?? [])
|
||||||
|
|
@ -368,7 +406,7 @@ export default function CreateSpotPage() {
|
||||||
// Also add as t tag for backwards compatibility and relay filtering
|
// Also add as t tag for backwards compatibility and relay filtering
|
||||||
eventTags.push(['t', selectedCategory]);
|
eventTags.push(['t', selectedCategory]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all freeform tags
|
// Add all freeform tags
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
eventTags.push(['t', tag]);
|
eventTags.push(['t', tag]);
|
||||||
|
|
@ -379,7 +417,7 @@ export default function CreateSpotPage() {
|
||||||
// Use imeta tag for NIP-92 compatibility
|
// Use imeta tag for NIP-92 compatibility
|
||||||
eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
|
eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add alt tag for NIP-31 (human-readable description)
|
// Add alt tag for NIP-31 (human-readable description)
|
||||||
eventTags.push(['alt', t('spotUi.altSkateSpot', { title: title.trim() })]);
|
eventTags.push(['alt', t('spotUi.altSkateSpot', { title: title.trim() })]);
|
||||||
|
|
||||||
|
|
@ -389,11 +427,11 @@ export default function CreateSpotPage() {
|
||||||
tags: eventTags,
|
tags: eventTags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
draft.clear();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('spotUi.spotAdded'),
|
title: t('spotUi.spotAdded'),
|
||||||
description: images.length === 0
|
description: t('spotUi.spotAddedDesc'),
|
||||||
? t('spotUi.spotAddedNoPhoto')
|
|
||||||
: t('spotUi.spotAddedDesc'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to spot detail using pubkey and identifier
|
// Navigate to spot detail using pubkey and identifier
|
||||||
|
|
@ -443,6 +481,32 @@ export default function CreateSpotPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Unsaved-draft banner */}
|
||||||
|
{draft.resumable && (
|
||||||
|
<div className="mb-4 flex items-center gap-3 rounded-xl border border-[#ccff00]/40 bg-[#ccff00]/5 p-3">
|
||||||
|
<History className="h-5 w-5 flex-shrink-0 text-[#ccff00]" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-bold text-white">{t('createSpot.draftTitle')}</p>
|
||||||
|
<p className="text-xs text-gray-400">{t('createSpot.draftBody')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={draft.resume}
|
||||||
|
className="min-h-0 flex-shrink-0 rounded-lg bg-[#ccff00] px-3 py-1.5 text-xs font-black uppercase text-black hover:bg-[#ccff00]/90"
|
||||||
|
>
|
||||||
|
{t('createSpot.draftResume')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={draft.discard}
|
||||||
|
aria-label={t('createSpot.draftDiscard')}
|
||||||
|
className="min-h-0 min-w-0 flex-shrink-0 rounded-full p-1 text-gray-500 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
|
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -452,188 +516,93 @@ export default function CreateSpotPage() {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Title */}
|
{/* ── Location (map picker) ─────────────────────────────── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base font-bold">{t('createSpot.locationLabel')} *</Label>
|
||||||
|
<SpotLocationPicker value={coords} onChange={handlePick} />
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={getCurrentLocation}
|
||||||
|
disabled={isGettingLocation}
|
||||||
|
>
|
||||||
|
{isGettingLocation ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{t('createSpot.gettingLocation')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Crosshair className="h-4 w-4 mr-2" />
|
||||||
|
{t('createSpot.useCurrentLocation')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-gray-400">{t('createSpot.tapToPlace')}</span>
|
||||||
|
</div>
|
||||||
|
{errors.location && (
|
||||||
|
<p className="text-xs font-medium text-red-400">{errors.location}</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Title ─────────────────────────────────────────────── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">{t('spots.spotName')} *</Label>
|
<Label htmlFor="title">{t('spots.spotName')} *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
placeholder={t('createSpot.namePlaceholder')}
|
placeholder={t('createSpot.namePlaceholder')}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => { setTitle(e.target.value); clearError('title'); }}
|
||||||
required
|
className={cn(errors.title && 'border-red-500/60')}
|
||||||
/>
|
/>
|
||||||
|
{errors.title && <p className="text-xs font-medium text-red-400">{errors.title}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* ── Description ───────────────────────────────────────── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">{t('spots.description')}</Label>
|
<Label htmlFor="description">{t('spots.description')} *</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
placeholder={t('createSpot.descriptionPlaceholder')}
|
placeholder={t('createSpot.descriptionPlaceholder')}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => { setDescription(e.target.value); clearError('description'); }}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
className={cn(errors.description && 'border-red-500/60')}
|
||||||
/>
|
/>
|
||||||
|
{errors.description && <p className="text-xs font-medium text-red-400">{errors.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location Address */}
|
{/* ── Spot type ─────────────────────────────────────────── */}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="location">{t('spots.address')}</Label>
|
|
||||||
<Input
|
|
||||||
id="location"
|
|
||||||
placeholder={t('createSpot.addressPlaceholder')}
|
|
||||||
value={location}
|
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Coordinates */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('createSpot.coordinates')} *</Label>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
placeholder={t('createSpot.latitudePlaceholder')}
|
|
||||||
value={latitude}
|
|
||||||
onChange={(e) => setLatitude(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
placeholder={t('createSpot.longitudePlaceholder')}
|
|
||||||
value={longitude}
|
|
||||||
onChange={(e) => setLongitude(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={getCurrentLocation}
|
|
||||||
disabled={isGettingLocation}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
{isGettingLocation ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
{t('createSpot.gettingLocation')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MapPin className="h-4 w-4 mr-2" />
|
|
||||||
{t('createSpot.useCurrentLocation')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Access Type */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-base font-bold">{t('createSpot.accessType')} *</Label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Public */}
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-all border-2",
|
|
||||||
accessType === 'public'
|
|
||||||
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
|
|
||||||
: "border-gray-700 hover:border-[#ff0040]/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setAccessType('public')}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="text-2xl">🌍</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-bold text-white mb-1">{t('createSpot.publicTitle')}</h4>
|
|
||||||
<p className="text-sm text-gray-400">{t('createSpot.publicDesc')}</p>
|
|
||||||
</div>
|
|
||||||
{accessType === 'public' && (
|
|
||||||
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center">
|
|
||||||
<span className="text-white text-xs">✓</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Private */}
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-all border-2",
|
|
||||||
accessType === 'private'
|
|
||||||
? "bg-[#ff6600]/10 border-[#ff6600] ring-2 ring-[#ff6600]/50"
|
|
||||||
: "border-gray-700 hover:border-[#ff6600]/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setAccessType('private')}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="text-2xl">🔒</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-bold text-white mb-1">{t('createSpot.privateTitle')}</h4>
|
|
||||||
<p className="text-sm text-gray-400">{t('createSpot.privateDesc')}</p>
|
|
||||||
</div>
|
|
||||||
{accessType === 'private' && (
|
|
||||||
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center">
|
|
||||||
<span className="text-white text-xs">✓</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Ask Locals */}
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-all border-2",
|
|
||||||
accessType === 'locals'
|
|
||||||
? "bg-[#ccff00]/10 border-[#ccff00] ring-2 ring-[#ccff00]/50"
|
|
||||||
: "border-gray-700 hover:border-[#ccff00]/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setAccessType('locals')}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="text-2xl">👥</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-bold text-white mb-1">{t('createSpot.localsTitle')}</h4>
|
|
||||||
<p className="text-sm text-gray-400">{t('createSpot.localsDesc')}</p>
|
|
||||||
</div>
|
|
||||||
{accessType === 'locals' && (
|
|
||||||
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center">
|
|
||||||
<span className="text-black text-xs">✓</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Info for Private/Locals */}
|
|
||||||
{(accessType === 'private' || accessType === 'locals') && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="contact">{t('createSpot.contactInfo')}</Label>
|
|
||||||
<Input
|
|
||||||
id="contact"
|
|
||||||
placeholder={t('createSpot.contactPlaceholder')}
|
|
||||||
value={contactInfo}
|
|
||||||
onChange={(e) => setContactInfo(e.target.value)}
|
|
||||||
className="bg-black border-[#ff6600]/30"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
{accessType === 'private'
|
|
||||||
? t('createSpot.contactHintPrivate')
|
|
||||||
: t('createSpot.contactHintLocals')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spot Category (required, mutually exclusive) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-bold">{t('spots.spotType')} *</Label>
|
<Label className="text-base font-bold">{t('spots.spotType')} *</Label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
|
@ -660,65 +629,15 @@ export default function CreateSpotPage() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{errors.type && <p className="text-xs font-medium text-red-400">{errors.type}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shelter / weather protection */}
|
{/* ── Photos (required) ─────────────────────────────────── */}
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-base font-bold">{t('spotUi.shelter')}</Label>
|
|
||||||
<p className="text-xs text-gray-400 -mt-1">
|
|
||||||
{t('spotUi.shelterHint')}
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{shelterOptions.map((option) => (
|
|
||||||
<Card
|
|
||||||
key={option.value}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
|
|
||||||
shelter === option.value
|
|
||||||
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
|
|
||||||
: "border-gray-700 hover:border-[#ff0040]/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setShelter(option.value)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-3 sm:p-4 relative">
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-bold text-sm sm:text-base text-white">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{shelter === option.value && (
|
|
||||||
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
|
|
||||||
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Freeform Tags */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-base font-bold">{t('spotUi.tags')}</Label>
|
|
||||||
<p className="text-xs text-gray-400 -mt-1">
|
|
||||||
{t('spotUi.tagsHint', { max: MAX_TAGS })}
|
|
||||||
</p>
|
|
||||||
<TagInput
|
|
||||||
tags={tags}
|
|
||||||
onChange={setTags}
|
|
||||||
placeholder={t('spotUi.typeToSearchTags')}
|
|
||||||
maxTags={MAX_TAGS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Images — required (see the guard in handleSubmit). */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
{t('spots.photos')}
|
{t('spots.photos')} *
|
||||||
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
|
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
|
||||||
{t('spotUi.photosRecommended')}
|
{t('createSpot.photoHint')}
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
@ -760,6 +679,7 @@ export default function CreateSpotPage() {
|
||||||
className="aspect-video"
|
className="aspect-video"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.photo && <p className="text-xs font-medium text-red-400">{errors.photo}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Camera Capture Dialog */}
|
{/* Camera Capture Dialog */}
|
||||||
|
|
@ -770,10 +690,191 @@ export default function CreateSpotPage() {
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── More details (optional) ───────────────────────────── */}
|
||||||
|
<Collapsible open={showMore} onOpenChange={setShowMore} className="rounded-xl border border-white/10">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between gap-2 p-4 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-black uppercase tracking-wide text-white">
|
||||||
|
{t('createSpot.moreDetails')}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={cn('h-4 w-4 text-gray-400 transition-transform', showMore && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-6 px-4 pb-4">
|
||||||
|
{/* Address */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="location">{t('spots.address')}</Label>
|
||||||
|
<Input
|
||||||
|
id="location"
|
||||||
|
placeholder={t('createSpot.addressPlaceholder')}
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Access Type */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-bold">{t('createSpot.accessType')}</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Public */}
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all border-2",
|
||||||
|
accessType === 'public'
|
||||||
|
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
|
||||||
|
: "border-gray-700 hover:border-[#ff0040]/50"
|
||||||
|
)}
|
||||||
|
onClick={() => setAccessType('public')}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-2xl">🌍</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-bold text-white mb-1">{t('createSpot.publicTitle')}</h4>
|
||||||
|
<p className="text-sm text-gray-400">{t('createSpot.publicDesc')}</p>
|
||||||
|
</div>
|
||||||
|
{accessType === 'public' && (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs">✓</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Private */}
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all border-2",
|
||||||
|
accessType === 'private'
|
||||||
|
? "bg-[#ff6600]/10 border-[#ff6600] ring-2 ring-[#ff6600]/50"
|
||||||
|
: "border-gray-700 hover:border-[#ff6600]/50"
|
||||||
|
)}
|
||||||
|
onClick={() => setAccessType('private')}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-2xl">🔒</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-bold text-white mb-1">{t('createSpot.privateTitle')}</h4>
|
||||||
|
<p className="text-sm text-gray-400">{t('createSpot.privateDesc')}</p>
|
||||||
|
</div>
|
||||||
|
{accessType === 'private' && (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs">✓</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Ask Locals */}
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all border-2",
|
||||||
|
accessType === 'locals'
|
||||||
|
? "bg-[#ccff00]/10 border-[#ccff00] ring-2 ring-[#ccff00]/50"
|
||||||
|
: "border-gray-700 hover:border-[#ccff00]/50"
|
||||||
|
)}
|
||||||
|
onClick={() => setAccessType('locals')}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-2xl">👥</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-bold text-white mb-1">{t('createSpot.localsTitle')}</h4>
|
||||||
|
<p className="text-sm text-gray-400">{t('createSpot.localsDesc')}</p>
|
||||||
|
</div>
|
||||||
|
{accessType === 'locals' && (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center">
|
||||||
|
<span className="text-black text-xs">✓</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Info for Private/Locals */}
|
||||||
|
{(accessType === 'private' || accessType === 'locals') && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contact">{t('createSpot.contactInfo')}</Label>
|
||||||
|
<Input
|
||||||
|
id="contact"
|
||||||
|
placeholder={t('createSpot.contactPlaceholder')}
|
||||||
|
value={contactInfo}
|
||||||
|
onChange={(e) => setContactInfo(e.target.value)}
|
||||||
|
className="bg-black border-[#ff6600]/30"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{accessType === 'private'
|
||||||
|
? t('createSpot.contactHintPrivate')
|
||||||
|
: t('createSpot.contactHintLocals')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shelter / weather protection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-bold">{t('spotUi.shelter')}</Label>
|
||||||
|
<p className="text-xs text-gray-400 -mt-1">
|
||||||
|
{t('spotUi.shelterHint')}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{shelterOptions.map((option) => (
|
||||||
|
<Card
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
|
||||||
|
shelter === option.value
|
||||||
|
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
|
||||||
|
: "border-gray-700 hover:border-[#ff0040]/50"
|
||||||
|
)}
|
||||||
|
onClick={() => setShelter(option.value)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-3 sm:p-4 relative">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-bold text-sm sm:text-base text-white">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shelter === option.value && (
|
||||||
|
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||||
|
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Freeform Tags */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-bold">{t('spotUi.tags')}</Label>
|
||||||
|
<p className="text-xs text-gray-400 -mt-1">
|
||||||
|
{t('spotUi.tagsHint', { max: MAX_TAGS })}
|
||||||
|
</p>
|
||||||
|
<TagInput
|
||||||
|
tags={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
placeholder={t('spotUi.typeToSearchTags')}
|
||||||
|
maxTags={MAX_TAGS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
{/* Quality Meter */}
|
{/* Quality Meter */}
|
||||||
<QualityMeter
|
<QualityMeter
|
||||||
type="spot"
|
type="spot"
|
||||||
hasLocation={!!(latitude && longitude)}
|
hasLocation={hasCoords}
|
||||||
hasPhotos={images.length > 0}
|
hasPhotos={images.length > 0}
|
||||||
photoCount={images.length}
|
photoCount={images.length}
|
||||||
hasDescription={description.trim().length > 0}
|
hasDescription={description.trim().length > 0}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue