This commit is contained in:
Anton Tranelis 2024-07-31 21:34:27 +02:00
commit 5a84c4f180
12 changed files with 207 additions and 133 deletions

5
package-lock.json generated
View File

@ -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",

View File

@ -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/",

View File

@ -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<HTMLTextAreaElement>(null);
const [inputValue, setInputValue] = useState<string>(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,11 +43,10 @@ export function TextAreaInput({ labelTitle, dataField, labelStyle, containerStyl
return ""
},
menuItemTemplate: function (item) {
return `<span style="color: ${item.original.color}; padding: 5px; boarder-radius: 3px;">#${item.string}</span>`;
return `<span style="color: ${item.original.color}; padding: 5px; border-radius: 3px;">#${item.string}</span>`;
}
});
useEffect(() => {
if (!init.current) {
if (ref.current) {
@ -57,16 +54,36 @@ export function TextAreaInput({ labelTitle, dataField, labelStyle, containerStyl
}
init.current = true;
}
}, [ref])
}, [ref]);
useEffect(() => {
setInputValue(defaultValue);
}, [defaultValue]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
if (updateFormValue) {
updateFormValue(newValue);
}
};
return (
<div className={`tw-form-control tw-w-full ${containerStyle ? containerStyle : ""}`}>
{labelTitle ? <label className="tw-label">
<span className={"tw-label-text tw-text-base-content " + labelStyle}>{labelTitle}</span>
</label> : ""}
<textarea required ref={ref} defaultValue={defaultValue} name={dataField} className={`tw-textarea tw-textarea-bordered tw-w-full tw-leading-5 ${inputStyle ? inputStyle : ""}`} placeholder={placeholder || ""} onChange={(e) => updateFormValue && updateFormValue(e.target.value)}></textarea>
{labelTitle ? (
<label className="tw-label">
<span className={`tw-label-text tw-text-base-content ${labelStyle}`}>{labelTitle}</span>
</label>
) : null}
<textarea
required
ref={ref}
value={inputValue}
name={dataField}
className={`tw-textarea tw-textarea-bordered tw-w-full tw-leading-5 ${inputStyle || ""}`}
placeholder={placeholder || ""}
onChange={handleChange}
></textarea>
</div>
)
);
}

View File

@ -167,7 +167,7 @@ export const SearchControl = () => {
))}
{isGeoCoordinate(value) &&
<div className='tw-flex tw-flex-row hover:tw-font-bold tw-cursor-pointer' onClick={() => {
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(`<h3 class="tw-text-base tw-font-bold">${extractCoordinates(value)![0]}, ${extractCoordinates(value)![1]}</h3>`).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(`<h3 class="tw-text-base tw-font-bold">${extractCoordinates(value)![0]}, ${extractCoordinates(value)![1]}</h3>`).openPopup().addEventListener("popupclose", (e) => { console.log(e.target.remove()) });
map.setView(new LatLng(extractCoordinates(value)![0], extractCoordinates(value)![1]), 15, { duration: 1 })
}}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="tw-text-current tw-mr-2 tw-mt-0 tw-w-4">

View File

@ -0,0 +1,16 @@
export const SelectPosition = ({ setSelectNewItemPosition }: { setSelectNewItemPosition }) => {
return (
<div className="tw-animate-pulseGrow tw-button tw-z-1000 tw-absolute tw-right-5 tw-top-4 tw-drop-shadow-md">
<label className="tw-btn tw-btn-sm tw-rounded-2xl tw-btn-circle tw-btn-ghost hover:tw-bg-transparent tw-absolute tw-right-0 tw-top-0 tw-text-gray-600" onClick={() => {
setSelectNewItemPosition(null)
}}>
<p className='tw-text-center '></p></label>
<div className="tw-alert tw-bg-base-100 tw-text-base-content">
<div>
<span className="tw-text-lg">Select position on the map!</span>
</div>
</div>
</div>
)
}

View File

@ -21,6 +21,7 @@ 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();
@ -52,7 +53,9 @@ function UtopiaMap({
const resetMetaTags = () => {
let params = new URLSearchParams(window.location.search);
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")}`);
@ -127,17 +130,7 @@ function UtopiaMap({
</MapContainer>
<AddButton triggerAction={setSelectNewItemPosition}></AddButton>
{selectNewItemPosition != null &&
<div className="tw-button tw-z-1000 tw-absolute tw-right-5 tw-top-4 tw-drop-shadow-md">
<label className="tw-btn tw-btn-sm tw-rounded-2xl tw-btn-circle tw-btn-ghost hover:tw-bg-transparent tw-absolute tw-right-0 tw-top-0 tw-text-gray-600" onClick={() => {
setSelectNewItemPosition(null)
}}>
<p className='tw-text-center '></p></label>
<div className="tw-alert tw-bg-base-100 tw-text-base-content">
<div>
<span>Select {selectNewItemPosition.name} position!</span>
</div>
</div>
</div>
<SelectPosition setSelectNewItemPosition={setSelectNewItemPosition} />
}
</div>

View File

@ -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])

View File

@ -19,7 +19,7 @@ import { useTags } from '../Map/hooks/useTags';
export function ProfileView({ userType, attestationApi }: { userType: string , attestationApi?: ItemsApi<any>}) {
const [item, setItem] = useState<Item>({} as Item)
const [item, setItem] = useState<Item>()
const [updatePermission, setUpdatePermission] = useState<boolean>(false);
const [relations, setRelations] = useState<Array<Item>>([]);
const [offers, setOffers] = useState<Array<Tag>>([]);
@ -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));

View File

@ -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<React.SetStateAction<any>>}) => {
interface AvatarWidgetProps {
avatar: string;
setAvatar: React.Dispatch<React.SetStateAction<any>>;
}
export const AvatarWidget: React.FC<AvatarWidgetProps> = ({ avatar, setAvatar }) => {
const [crop, setCrop] = useState<Crop>();
const [image, setImage] = useState<string>("");
const [cropModalOpen, setCropModalOpen] = useState<boolean>(false);
@ -16,31 +18,38 @@ export const AvatarWidget = ({avatar, setAvatar}:{avatar:string, setAvatar : Rea
const assetsApi = useAssetApi();
const imgRef = useRef<HTMLImageElement>(null);
const onImageChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLImageElement>(null)
const onImageChange = (event) => {
if (event.target.files && event.target.files[0]) {
setImage(URL.createObjectURL(event.target.files[0]));
if (!validFormats.includes(file.type)) {
alert("Unsupported file format. Please upload a JPEG or PNG image.");
return;
}
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.");
}
}, []);
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { width, height } = e.currentTarget
const onImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
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<HTMLImageElement> {
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;
}
async function renderCrop() {
// get the image element
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;
}
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
</div>
}
</label>
: <div className='tw-w-20 tw-flex tw-items-center tw-justify-center'>
<span className="tw-loading tw-loading-spinner"></span>
</div>
}
<DialogModal
title=""
@ -159,5 +192,5 @@ export const AvatarWidget = ({avatar, setAvatar}:{avatar:string, setAvatar : Rea
}}>Select</button>
</DialogModal>
</>
)
}
);
};

View File

@ -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"
@ -49,10 +49,14 @@ export const TabsForm = ({ item, state, setState, updatePermission, linkItem, un
}))}></PopupStartEndInput>
}
<TextAreaInput placeholder="about ..." defaultValue={item?.text ? item.text : ""} updateFormValue={(v) => setState(prevState => ({
<TextAreaInput placeholder="about ..."
defaultValue={item?.text ? item.text : ""}
updateFormValue={(v) => 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"}`} />
}))}
containerStyle='tw-grow'
inputStyle={`tw-h-full ${!item.layer.itemType.show_start_end_input && "tw-border-t-0 tw-rounded-tl-none"}`} />
<div>
<TextAreaInput
placeholder="contact info ..."

View File

@ -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 }

View File

@ -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"),