diff --git a/package.json b/package.json index d31565e..cfe0aaa 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@hello-pangea/dnd": "^17.0.0", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-scroll-area": "^1.2.0", diff --git a/public/layers-2x.png b/public/layers-2x.png new file mode 100644 index 0000000..200c333 Binary files /dev/null and b/public/layers-2x.png differ diff --git a/public/layers.png b/public/layers.png new file mode 100644 index 0000000..1a72e57 Binary files /dev/null and b/public/layers.png differ diff --git a/public/marker-icon-2x.png b/public/marker-icon-2x.png new file mode 100644 index 0000000..88f9e50 Binary files /dev/null and b/public/marker-icon-2x.png differ diff --git a/public/marker-icon.png b/public/marker-icon.png new file mode 100644 index 0000000..950edf2 Binary files /dev/null and b/public/marker-icon.png differ diff --git a/public/marker-shadow.png b/public/marker-shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/public/marker-shadow.png differ diff --git a/src/index.css b/src/index.css index f263c0f..a5f516c 100644 --- a/src/index.css +++ b/src/index.css @@ -8,100 +8,115 @@ } :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 500; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 500; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } body { - margin: auto; - min-width: 320px; - min-height: 100vh; + margin: auto; + min-width: 320px; + min-height: 100vh; } @media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } + :root { + color: #213547; + background-color: #ffffff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } +} + +@layer utilities { + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } } @layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; - } - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } } @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } } diff --git a/src/modules/dashboard-sidebar.module.tsx b/src/modules/dashboard-sidebar.module.tsx index 3a77b18..5697f76 100644 --- a/src/modules/dashboard-sidebar.module.tsx +++ b/src/modules/dashboard-sidebar.module.tsx @@ -24,13 +24,13 @@ export default function DashboardSidebar() { onChange={(e) => setFilter(e.target.value)} /> -
+
{filteredPlaces?.map((x, index) => (
{ navigate(`${x.id}`); }} className="cursor-pointer px-3 py-2 h-24 hover:bg-gray-100 w-full">

- {x.title} + {x.title} {x.id}

{x.address} diff --git a/src/modules/place.module.tsx b/src/modules/place.module.tsx index 2238e12..8cd462e 100644 --- a/src/modules/place.module.tsx +++ b/src/modules/place.module.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useState } from "react"; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from "@hello-pangea/dnd"; + import { Place, Tag } from "@/interfaces/place.interface"; import { Plus, X } from "lucide-react"; import { Label } from "@/components/ui/label"; @@ -9,6 +16,9 @@ import { TagComponent } from "@/components/tag"; import { Button } from "@/components/ui/button"; import { useDashboardStore } from "@/shared/stores/places.store"; +import { uploadImageByUrl } from "@/shared/api/parse.api"; +import { deletePlace } from "@/shared/api/places.api"; + export const PlaceModule = ({ inputPlace, onSave, @@ -19,7 +29,7 @@ export const PlaceModule = ({ const [place, setPlace] = useState(inputPlace); useEffect(() => { - setPlace(inputPlace); + if (inputPlace.title !== "") setPlace(inputPlace); }, [inputPlace]); const { tags } = useDashboardStore(); @@ -42,14 +52,17 @@ export const PlaceModule = ({ [place] ); - const handleAddPhoto = () => { + const handleAddPhoto = async () => { if (!place) return; - const newPhotoUrl = prompt("Enter the URL of the new photo:"); - if (newPhotoUrl) { - setPlace({ - ...place, - images: [...place.images, newPhotoUrl], - }); + const rawPhotoUrl = prompt("Enter the URL of the new photo:"); + if (rawPhotoUrl) { + const newPhotoUrl = await uploadImageByUrl(rawPhotoUrl); + if (newPhotoUrl.url) { + setPlace({ + ...place, + images: [...place.images, newPhotoUrl.url], + }); + } } }; @@ -75,47 +88,81 @@ export const PlaceModule = ({ }; const handleSave = useCallback(() => { - place.location.lat = Number(place.location.lat); place.location.lon = Number(place.location.lon); - + place.location.lat = Number(place.location.lat); place.priceAvg = Number(place.priceAvg); - onSave(place); }, [place, onSave]); + const handleDelete = () => { + deletePlace(place); + }; + const handleDragEnd = (result: DropResult) => { + if (!result.destination) return; + + const reorderedImages = Array.from(place.images); + const [movedImage] = reorderedImages.splice(result.source.index, 1); + reorderedImages.splice(result.destination.index, 0, movedImage); + + setPlace((prev) => ({ ...prev, images: reorderedImages })); + + console.log(place); + }; + return place !== null ? (

-
-
- {place.images.map((img, index) => ( -
- Place +
+ + + {(provided) => (
handleRemovePhoto(index)} + {...provided.droppableProps} + ref={provided.innerRef} + className="whitespace-nowrap h-fit space-x-5 text-gray-300 flex" > - + {place.images.map((img, index) => ( + + {(provided) => ( +
+ Place +
handleRemovePhoto(index)} + > + +
+
+ )} +
+ ))} + {provided.placeholder} +
+
+ +
+
-
- ))} -
-
- -
-
-
+ )} + +
-
+
@@ -211,7 +258,16 @@ export const PlaceModule = ({
-
+ +
+ + diff --git a/src/pages/add-place.page.tsx b/src/pages/add-place.page.tsx index 6909d7d..a664e1d 100644 --- a/src/pages/add-place.page.tsx +++ b/src/pages/add-place.page.tsx @@ -1,31 +1,51 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; + +import { Textarea } from "@/components/ui/textarea"; + import { Place } from "@/interfaces/place.interface"; import { PlaceModule } from "@/modules/place.module"; import { parsePlace } from "@/shared/api/parse.api"; import { usePlaces } from "@/shared/hooks/usePlaces"; -import React, { useState } from 'react'; + +import React, { useState } from "react"; import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; export const AddPlacePage = () => { - const [url, setUrl] = useState(''); - const [parsedPlace, setParsedPlace] = useState | null>(null); + const [url, setUrl] = useState(""); + const [jsonInput, setJsonInput] = useState(""); + const [parsedPlace, setParsedPlace] = useState>({ + address: "", + description: "", + images: [], + location: { lat: 0, lon: 0 }, + priceAvg: 0, + reviewCount: 0, + reviewRating: 0, + shortDescription: "", + tags: [], + title: "", + updatedAt: "" + }); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - + const [error, setError] = useState(""); const navigate = useNavigate(); - const { savePlace } = usePlaces(); const handleInputChange = (e: React.ChangeEvent) => { setUrl(e.target.value); }; - const handleParse = async () => { - setError(''); + + const handleJsonChange = (e: React.ChangeEvent) => { + setJsonInput(e.target.value); + }; + + const handleParseUrl = async () => { + setError(""); if (!url) { - setError('URL cannot be empty.'); + setError("URL cannot be empty."); return; } @@ -34,41 +54,65 @@ export const AddPlacePage = () => { const place = await parsePlace(url); setParsedPlace(place); } catch { - setError('Failed to parse the place. Please check the URL or API key.'); + setError("Failed to parse the place. Please check the URL or API key."); } finally { setLoading(false); } }; + const handleParseJson = () => { + setError(""); + try { + const place = JSON.parse(jsonInput); + if (typeof place !== "object" || !place.title) { + throw new Error("Invalid JSON structure."); + } + setParsedPlace(place); + } catch { + setError("Invalid JSON format."); + } + }; + const handleSave = async (place: Place) => { const newPlace = await savePlace.mutateAsync(place); navigate(`/dashboard/${newPlace.id}`); - toast.success("Place updated"); - } + toast.success("Place saved successfully!"); + }; return ( -
-
-
+
+ {/* URL Input Section */} +
+
+ {loading ? "Parsing..." : "Parse URL"} + +
+
+