diff --git a/package-lock.json b/package-lock.json index a9d10dc0..25fd88d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "utopia-ui", - "version": "3.0.0-alpha.253", + + "version": "3.0.0-alpha.257", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "utopia-ui", - "version": "3.0.0-alpha.253", + "version": "3.0.0-alpha.257", "license": "MIT", "dependencies": { "@heroicons/react": "^2.0.17", diff --git a/package.json b/package.json index b893ca38..46355494 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "utopia-ui", - "version": "3.0.0-alpha.253", + "version": "3.0.0-alpha.257", "description": "Reuseable React Components to build mapping apps for real life communities and networks", "repository": "https://github.com/utopia-os/utopia-ui", "homepage:": "https://utopia-os.org/", diff --git a/src/Components/Input/TextAreaInput.tsx b/src/Components/Input/TextAreaInput.tsx index a2e79426..8ee5a7f1 100644 --- a/src/Components/Input/TextAreaInput.tsx +++ b/src/Components/Input/TextAreaInput.tsx @@ -1,9 +1,8 @@ -import * as React from "react" -import { useEffect, useRef } from "react"; +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; import Tribute from "tributejs"; import { useTags } from "../Map/hooks/useTags"; - type TextAreaProps = { labelTitle?: string; labelStyle?: string; @@ -19,21 +18,20 @@ interface KeyValue { [key: string]: string; } - export function TextAreaInput({ labelTitle, dataField, labelStyle, containerStyle, inputStyle, defaultValue, placeholder, updateFormValue }: TextAreaProps) { - const ref = useRef(null); + const [inputValue, setInputValue] = useState(defaultValue); // prevent react18 from calling useEffect twice - const init = useRef(false) + const init = useRef(false); const tags = useTags(); let values: KeyValue[] = []; - tags.map(tag => { - values.push({ key: tag.name, value: tag.name, color: tag.color }) - }) + tags.forEach(tag => { + values.push({ key: tag.name, value: tag.name, color: tag.color }); + }); var tribute = new Tribute({ containerClass: 'tw-z-3000 tw-bg-base-100 tw-p-2 tw-rounded-lg tw-shadow', @@ -45,28 +43,47 @@ export function TextAreaInput({ labelTitle, dataField, labelStyle, containerStyl return "" }, menuItemTemplate: function (item) { - return `#${item.string}`; + return `#${item.string}`; } }); - useEffect(() => { if (!init.current) { if (ref.current) { tribute.attach(ref.current); } init.current = true; - } - }, [ref]) + } + }, [ref]); + + useEffect(() => { + setInputValue(defaultValue); + }, [defaultValue]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + if (updateFormValue) { + updateFormValue(newValue); + } + }; return (
- {labelTitle ? : ""} - + {labelTitle ? ( + + ) : null} +
- ) + ); } - - diff --git a/src/Components/Map/Subcomponents/Controls/SearchControl.tsx b/src/Components/Map/Subcomponents/Controls/SearchControl.tsx index d00a534f..f4d1e5d8 100644 --- a/src/Components/Map/Subcomponents/Controls/SearchControl.tsx +++ b/src/Components/Map/Subcomponents/Controls/SearchControl.tsx @@ -167,7 +167,7 @@ export const SearchControl = () => { ))} {isGeoCoordinate(value) &&
{ - L.marker(new LatLng(extractCoordinates(value)![0], extractCoordinates(value)![1]), { icon: MarkerIconFactory("circle", "#777", "RGBA(35, 31, 32, 0.2)", "circle-solid") }).addTo(map).bindPopup(`

${extractCoordinates(value)![0]}, ${extractCoordinates(value)![1]}

`).openPopup().addEventListener("popupclose", (e) => { console.log(e.target.remove()) }); + L.marker(new LatLng(extractCoordinates(value)![0], extractCoordinates(value)![1]), { icon: MarkerIconFactory("circle", "#777", "RGBA(35, 31, 32, 0.2)", "point") }).addTo(map).bindPopup(`

${extractCoordinates(value)![0]}, ${extractCoordinates(value)![1]}

`).openPopup().addEventListener("popupclose", (e) => { console.log(e.target.remove()) }); map.setView(new LatLng(extractCoordinates(value)![0], extractCoordinates(value)![1]), 15, { duration: 1 }) }}> diff --git a/src/Components/Map/Subcomponents/SelectPosition.tsx b/src/Components/Map/Subcomponents/SelectPosition.tsx new file mode 100644 index 00000000..a3144606 --- /dev/null +++ b/src/Components/Map/Subcomponents/SelectPosition.tsx @@ -0,0 +1,16 @@ + +export const SelectPosition = ({ setSelectNewItemPosition }: { setSelectNewItemPosition }) => { + return ( +
+ +
+
+ Select position on the map! +
+
+
+ ) +} diff --git a/src/Components/Map/UtopiaMap.tsx b/src/Components/Map/UtopiaMap.tsx index ec1b0d52..5f591346 100644 --- a/src/Components/Map/UtopiaMap.tsx +++ b/src/Components/Map/UtopiaMap.tsx @@ -13,14 +13,15 @@ import { SearchControl } from "./Subcomponents/Controls/SearchControl"; import { Control } from "./Subcomponents/Controls/Control"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { TagsControl } from "./Subcomponents/Controls/TagsControl"; -import { useSelectPosition, useSetMapClicked,useSetSelectPosition } from "./hooks/useSelectPosition"; +import { useSelectPosition, useSetMapClicked, useSetSelectPosition } from "./hooks/useSelectPosition"; import { useClusterRef, useSetClusterRef } from "./hooks/useClusterRef"; import { Feature, Geometry as GeoJSONGeometry } from 'geojson'; -import {FilterControl} from "./Subcomponents/Controls/FilterControl"; -import {LayerControl} from "./Subcomponents/Controls/LayerControl"; +import { FilterControl } from "./Subcomponents/Controls/FilterControl"; +import { LayerControl } from "./Subcomponents/Controls/LayerControl"; import { useLayers } from "./hooks/useLayers"; import { useAddVisibleLayer } from "./hooks/useFilter"; import { GratitudeControl } from "./Subcomponents/Controls/GratitudeControl"; +import { SelectPosition } from "./Subcomponents/SelectPosition"; // for refreshing map on resize (needs to be implemented) const mapDivRef = React.createRef(); @@ -32,9 +33,9 @@ function UtopiaMap({ zoom = 10, children, geo, - showFilterControl=false, + showFilterControl = false, showLayerControl = true - } +} : UtopiaMapProps) { function MapEventListener() { @@ -52,7 +53,9 @@ function UtopiaMap({ const resetMetaTags = () => { let params = new URLSearchParams(window.location.search); - window.history.pushState({}, "", `/` + `${params.toString() !== "" ? `?${params}` : ""}`) + if (!location.pathname.includes("/item/")) { + window.history.pushState({}, "", `/` + `${params.toString() !== "" ? `?${params}` : ""}`) + } document.title = document.title.split("-")[0]; document.querySelector('meta[property="og:title"]')?.setAttribute("content", document.title); document.querySelector('meta[property="og:description"]')?.setAttribute("content", `${document.querySelector('meta[name="description"]')?.getAttribute("content")}`); @@ -75,9 +78,9 @@ function UtopiaMap({ useEffect(() => { layers.map(l => addVisibleLayer(l)) - + }, [layers]) - + @@ -100,7 +103,7 @@ function UtopiaMap({ {/*{!embedded && (*/} {/* */} {/*)}*/} - {showFilterControl && } + {showFilterControl && } {/*todo: needed layer handling is located LayerControl*/} {showLayerControl && } {} @@ -125,19 +128,9 @@ function UtopiaMap({ }} />} - + {selectNewItemPosition != null && -
- -
-
- Select {selectNewItemPosition.name} position! -
-
-
+ }
diff --git a/src/Components/Map/hooks/useSelectPosition.tsx b/src/Components/Map/hooks/useSelectPosition.tsx index 464d0409..5fb60b0d 100644 --- a/src/Components/Map/hooks/useSelectPosition.tsx +++ b/src/Components/Map/hooks/useSelectPosition.tsx @@ -36,7 +36,7 @@ function useSelectPositionManager(): { useEffect(() => { - if (selectPosition && markerClicked && 'text' in selectPosition) { + if (selectPosition && markerClicked && 'text' in selectPosition && markerClicked.id !==selectPosition.id) { itemUpdateParent({ ...selectPosition, parent: markerClicked.id }) } }, [markerClicked]) diff --git a/src/Components/Profile/ProfileView.tsx b/src/Components/Profile/ProfileView.tsx index 36b369b7..aafac35a 100644 --- a/src/Components/Profile/ProfileView.tsx +++ b/src/Components/Profile/ProfileView.tsx @@ -19,7 +19,7 @@ import { useTags } from '../Map/hooks/useTags'; export function ProfileView({ userType, attestationApi }: { userType: string , attestationApi?: ItemsApi}) { - const [item, setItem] = useState({} as Item) + const [item, setItem] = useState() const [updatePermission, setUpdatePermission] = useState(false); const [relations, setRelations] = useState>([]); const [offers, setOffers] = useState>([]); @@ -68,15 +68,15 @@ export function ProfileView({ userType, attestationApi }: { userType: string , a setNeeds([]); setRelations([]); - item.layer?.itemOffersField && getValue(item, item.layer.itemOffersField)?.map(o => { + item?.layer?.itemOffersField && getValue(item, item.layer.itemOffersField)?.map(o => { const tag = tags.find(t => t.id === o.tags_id); tag && setOffers(current => [...current, tag]) }) - item.layer?.itemNeedsField && getValue(item, item.layer.itemNeedsField)?.map(n => { + item?.layer?.itemNeedsField && getValue(item, item.layer.itemNeedsField)?.map(n => { const tag = tags.find(t => t.id === n.tags_id); tag && setNeeds(current => [...current, tag]) }) - item.relations?.map(r => { + item?.relations?.map(r => { const item = items.find(i => i.id == r.related_items_id) item && setRelations(current => [...current, item]) }) @@ -133,7 +133,7 @@ export function ProfileView({ userType, attestationApi }: { userType: string , a }, [selectPosition]) useEffect(() => { - setTemplate(item.layer?.itemType.template || userType); + setTemplate(item?.layer?.itemType.template || userType); }, [userType, item]) const [urlParams, setUrlParams] = useState(new URLSearchParams(location.search)); @@ -141,7 +141,7 @@ export function ProfileView({ userType, attestationApi }: { userType: string , a return ( <> - {item && + {item && <>
diff --git a/src/Components/Profile/Subcomponents/AvatarWidget.tsx b/src/Components/Profile/Subcomponents/AvatarWidget.tsx index e513bb94..43941a33 100644 --- a/src/Components/Profile/Subcomponents/AvatarWidget.tsx +++ b/src/Components/Profile/Subcomponents/AvatarWidget.tsx @@ -1,14 +1,16 @@ import * as React from "react"; -import { useState } from "react"; +import { useState, useCallback, useRef } from "react"; import ReactCrop, { Crop, centerCrop, makeAspectCrop } from 'react-image-crop'; import { useAssetApi } from '../../AppShell/hooks/useAssets'; +import 'react-image-crop/dist/ReactCrop.css'; import DialogModal from "../../Templates/DialogModal"; -import 'react-image-crop/dist/ReactCrop.css' - - -export const AvatarWidget = ({avatar, setAvatar}:{avatar:string, setAvatar : React.Dispatch>}) => { +interface AvatarWidgetProps { + avatar: string; + setAvatar: React.Dispatch>; +} +export const AvatarWidget: React.FC = ({ avatar, setAvatar }) => { const [crop, setCrop] = useState(); const [image, setImage] = useState(""); const [cropModalOpen, setCropModalOpen] = useState(false); @@ -16,31 +18,38 @@ export const AvatarWidget = ({avatar, setAvatar}:{avatar:string, setAvatar : Rea const assetsApi = useAssetApi(); + const imgRef = useRef(null); + const onImageChange = useCallback((event: React.ChangeEvent) => { + const file = event.target.files && event.target.files[0]; + if (file) { + const validFormats = ["image/jpeg", "image/png"]; + const maxSizeMB = 10; + const maxSizeBytes = maxSizeMB * 1024 * 1024; - const imgRef = React.useRef(null) + if (!validFormats.includes(file.type)) { + alert("Unsupported file format. Please upload a JPEG or PNG image."); + return; + } - const onImageChange = (event) => { - if (event.target.files && event.target.files[0]) { - setImage(URL.createObjectURL(event.target.files[0])); + if (file.size > maxSizeBytes) { + alert(`File size exceeds ${maxSizeMB}MB. Please upload a smaller image.`); + return; + } + + setImage(URL.createObjectURL(file)); + setCropModalOpen(true); + } else { + alert("No file selected or an error occurred while selecting the file."); } - setCropModalOpen(true); - } + }, []); - function onImageLoad(e: React.SyntheticEvent) { - const { width, height } = e.currentTarget + const onImageLoad = useCallback((e: React.SyntheticEvent) => { + const { width, height } = e.currentTarget; + setCrop(centerAspectCrop(width, height, 1)); + }, []); - setCrop(centerAspectCrop(width, height, 1)) - } - - - // This is to demonstate how to make and center a % aspect crop - // which is a bit trickier so we use some helper functions. - function centerAspectCrop( - mediaWidth: number, - mediaHeight: number, - aspect: number, - ) { + const centerAspectCrop = (mediaWidth: number, mediaHeight: number, aspect: number) => { return centerCrop( makeAspectCrop( { @@ -53,22 +62,50 @@ export const AvatarWidget = ({avatar, setAvatar}:{avatar:string, setAvatar : Rea ), mediaWidth, mediaHeight, - ) + ); + }; + + async function resizeImage(image: HTMLImageElement, maxWidth: number, maxHeight: number): Promise { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + let width = image.width; + let height = image.height; + + if (width > maxWidth) { + height *= maxWidth / width; + width = maxWidth; + } + if (height > maxHeight) { + width *= maxHeight / height; + height = maxHeight; + } + + canvas.width = width; + canvas.height = height; + + if (ctx) { + ctx.drawImage(image, 0, 0, width, height); + } + + const resizedImage = new Image(); + resizedImage.src = canvas.toDataURL(); + + await resizedImage.decode(); + return resizedImage; } - async function renderCrop() { - // get the image element + const renderCrop = useCallback(async () => { const image = imgRef.current; if (crop && image) { + const resizedImage = await resizeImage(image, 1024, 1024); // Bildgröße vor dem Zuschneiden reduzieren + const scaleX = resizedImage.naturalWidth / resizedImage.width; + const scaleY = resizedImage.naturalHeight / resizedImage.height; - const scaleX = image.naturalWidth / image.width - const scaleY = image.naturalHeight / image.height - - // create a canvas element to draw the cropped image const canvas = new OffscreenCanvas( crop.width * scaleX, - crop.height * scaleY, - ) + crop.height * scaleY + ); const ctx = canvas.getContext("2d"); const pixelRatio = window.devicePixelRatio; canvas.width = crop.width * pixelRatio * scaleX; @@ -76,9 +113,8 @@ export const AvatarWidget = ({avatar, setAvatar}:{avatar:string, setAvatar : Rea if (ctx) { ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); - ctx.drawImage( - image, + resizedImage, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, @@ -89,28 +125,27 @@ export const AvatarWidget = ({avatar, setAvatar}:{avatar:string, setAvatar : Rea crop.height * scaleY ); } + const blob = await canvas.convertToBlob(); await resizeBlob(blob); setCropping(false); setImage(""); } - } + }, [crop]); - async function resizeBlob(blob) { - var img = new Image(); + const resizeBlob = useCallback(async (blob: Blob) => { + const img = new Image(); img.src = URL.createObjectURL(blob); await img.decode(); - const canvas = new OffscreenCanvas( - 400, - 400 - ) - var ctx = canvas.getContext("2d"); - ctx?.drawImage(img, 0, 0, 400, 400); - const resizedBlob = await canvas.convertToBlob() - const asset = await assetsApi.upload(resizedBlob, "test"); - setAvatar(asset.id) - } + const canvas = new OffscreenCanvas(400, 400); + const ctx = canvas.getContext("2d"); + ctx?.drawImage(img, 0, 0, 400, 400); + + const resizedBlob = await canvas.convertToBlob(); + const asset = await assetsApi.upload(resizedBlob, "avatar"); + setAvatar(asset.id); + }, [assetsApi, setAvatar]); return ( <> @@ -135,11 +170,9 @@ export const AvatarWidget = ({avatar, setAvatar}:{avatar:string, setAvatar : Rea
} - :
- } Select - ) -} + ); +}; diff --git a/src/Components/Profile/Templates/TabsForm.tsx b/src/Components/Profile/Templates/TabsForm.tsx index 3ca3c5e2..9380faf6 100644 --- a/src/Components/Profile/Templates/TabsForm.tsx +++ b/src/Components/Profile/Templates/TabsForm.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react" -import { TextAreaInput, TextInput } from "../../Input" +import { TextAreaInput } from "../../Input" import { PopupStartEndInput, TextView } from "../../Map" import { ActionButton } from "../Subcomponents/ActionsButton" import { LinkedItemsHeaderView } from "../Subcomponents/LinkedItemsHeaderView" @@ -15,46 +15,50 @@ export const TabsForm = ({ item, state, setState, updatePermission, linkItem, un const updateActiveTab = useCallback((id: number) => { setActiveTab(id); - + let params = new URLSearchParams(window.location.search); params.set("tab", `${id}`); const newUrl = location.pathname + "?" + params.toString(); window.history.pushState({}, '', newUrl); setUrlParams(params); - }, [location.pathname]); - - useEffect(() => { + }, [location.pathname]); + + useEffect(() => { let params = new URLSearchParams(location.search); let urlTab = params.get("tab"); setActiveTab(urlTab ? Number(urlTab) : 1); - }, [location.search]); + }, [location.search]); return (
updateActiveTab(1)} />
- {item.layer.itemType.show_start_end_input && - setState(prevState => ({ - ...prevState, - end: e - }))} - updateStartValue={(s) => setState(prevState => ({ - ...prevState, - start: s - }))}> - } + {item.layer.itemType.show_start_end_input && + setState(prevState => ({ + ...prevState, + end: e + }))} + updateStartValue={(s) => setState(prevState => ({ + ...prevState, + start: s + }))}> + } - setState(prevState => ({ - ...prevState, - text: v - }))} containerStyle='tw-grow' inputStyle={`tw-h-full ${!item.layer.itemType.show_start_end_input && "tw-border-t-0 tw-rounded-tl-none"}`} /> + setState(prevState => ({ + ...prevState, + text: v + }))} + containerStyle='tw-grow' + inputStyle={`tw-h-full ${!item.layer.itemType.show_start_end_input && "tw-border-t-0 tw-rounded-tl-none"}`} />
- setState(prevState => ({ diff --git a/src/Components/Profile/itemFunctions.ts b/src/Components/Profile/itemFunctions.ts index 25343946..f91ad5d8 100644 --- a/src/Components/Profile/itemFunctions.ts +++ b/src/Components/Profile/itemFunctions.ts @@ -125,18 +125,18 @@ export const onUpdateItem = async (state, item, tags, addTag, setLoading, naviga changedItem = { id: state.id, name: state.name, - ...state.subname && {subname: state.subname}, - ...state.text && {text: state.text}, + subname: state.subname, + text: state.text, ...state.color && {color: state.color}, position: item.position, ...state.groupType && {group_type: state.groupType}, ...state.status && {status: state.status}, - ...state.contact && {contact: state.contact}, - ...state.telephone && {telephone: state.telephone}, + contact: state.contact, + telephone: state.telephone, ...state.end && {end: state.end}, ...state.start && {start: state.start}, ...state.markerIcon && { markerIcon: state.markerIcon }, - ...state.nextAppointment && {next_appointment: state.nextAppointment}, + next_appointment: state.nextAppointment, ...state.image.length > 10 && { image: state.image }, ...state.offers.length > 0 && { offers: offer_updates }, ...state.needs.length > 0 && { needs: needs_updates } diff --git a/tailwind.config.js b/tailwind.config.js index a49dda38..79de417a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -56,6 +56,16 @@ module.exports = { 'map': "1.4em" } }, + keyframes: { + pulseGrow: { + '0%, 100%': { transform: 'scale(1.00)' }, + '80%': { transform: 'scale(1.00)' }, + '90%': { transform: 'scale(0.95)' }, + }, + }, + animation: { + pulseGrow: 'pulseGrow 2s ease-in-out infinite', + }, }, plugins: [ require("daisyui"),