merged main

This commit is contained in:
Anton Tranelis 2025-07-21 17:29:53 +02:00
commit 6281ab0b49
51 changed files with 951 additions and 329 deletions

View File

@ -1,6 +1,12 @@
name: build:lib
on: push
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
files-changed:
@ -37,7 +43,7 @@ jobs:
build-examples:
if: needs.files-changed.outputs.build == 'true'
name: Test Example Apps
needs: [build, files-changed]
needs: [files-changed, build]
runs-on: ubuntu-latest
strategy:
matrix:

View File

@ -1 +1,4 @@
VITE_OPEN_COLLECTIVE_API_KEY=your_key
VITE_OPEN_COLLECTIVE_API_KEY=your_key
VITE_API_URL=https://api.utopia-lab.org
VITE_VALIDATE_INVITE_FLOW_ID=01d61db0-25aa-4bfa-bc24-c6a8f208a455
VITE_REDEEM_INVITE_FLOW_ID=cc80ec73-ecf5-4789-bee5-1127fb1a6ed4

View File

@ -82,7 +82,7 @@ module.exports = {
'import/no-relative-parent-imports': [
'error',
{
ignore: ['#[src,types,root,components,utils,assets]/*'],
ignore: ['#[src,types,root,components,utils,assets]/*', '@/config/*'],
},
],
'import/no-self-import': 'error',

1
app/.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/
dist/
.DS_Store

50
app/package-lock.json generated
View File

@ -18,7 +18,8 @@
"react-dom": "^18.2.0",
"react-rnd": "^10.4.1",
"react-router-dom": "^6.23.0",
"utopia-ui": "^3.0.111"
"utopia-ui": "^3.0.111",
"vite-tsconfig-paths": "^5.1.4"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
@ -6775,6 +6776,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -10662,6 +10669,26 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"license": "MIT",
"bin": {
"tsconfck": "bin/tsconfck.js"
},
"engines": {
"node": "^18 || >=20"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -10825,7 +10852,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -11302,6 +11329,25 @@
}
}
},
"node_modules/vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
},
"peerDependencies": {
"vite": "*"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",

View File

@ -20,6 +20,7 @@
"react-dom": "^18.2.0",
"react-rnd": "^10.4.1",
"react-router-dom": "^6.23.0",
"vite-tsconfig-paths": "^5.1.4",
"utopia-ui": "^3.0.111"
},
"devDependencies": {

View File

@ -20,6 +20,7 @@ import {
Content,
AuthProvider,
Modal,
InvitePage,
LoginPage,
SignupPage,
Quests,
@ -33,8 +34,8 @@ import {
MarketView,
SVG,
LoadingMapOverlay,
ProfileView,
ProfileForm,
ProfileView,
UserSettings,
} from 'utopia-ui'
@ -48,11 +49,16 @@ import { itemsApi } from './api/itemsApi'
import { layersApi } from './api/layersApi'
import { mapApi } from './api/mapApi'
import { permissionsApi } from './api/permissionsApi'
import { userApi } from './api/userApi'
import { UserApi } from './api/userApi'
import { ModalContent } from './ModalContent'
import { Landingpage } from './pages/Landingpage'
import MapContainer from './pages/MapContainer'
import { getBottomRoutes, routes } from './routes/sidebar'
import { config } from '@/config'
import { InviteApi } from './api/inviteApi'
const userApi = new UserApi()
const inviteApi = new InviteApi(userApi)
function App() {
const [permissionsApiInstance, setPermissionsApiInstance] = useState<permissionsApi>()
@ -140,12 +146,12 @@ function App() {
if (map && layers)
return (
<div className='App tw:overflow-x-hidden'>
<AuthProvider userApi={new userApi()}>
<AuthProvider userApi={userApi} inviteApi={inviteApi}>
<AppShell
assetsApi={new assetsApi('https://api.utopia-lab.org/assets/')}
appName={map.name}
embedded={embedded}
openCollectiveApiKey={import.meta.env.VITE_OPEN_COLLECTIVE_API_KEY}
openCollectiveApiKey={config.openCollectiveApiKey}
>
<Permissions
api={permissionsApiInstance}
@ -157,7 +163,8 @@ function App() {
<Quests />
<Routes>
<Route path='/*' element={<MapContainer map={map} layers={layers} />}>
<Route path='login' element={<LoginPage />} />
<Route path='invite/:id' element={<InvitePage inviteApi={inviteApi} />} />
<Route path='login' element={<LoginPage inviteApi={inviteApi} />} />
<Route path='signup' element={<SignupPage />} />
<Route
path='reset-password'

View File

@ -17,6 +17,14 @@ export function Welcome1({ clickAction1, map }: ChapterProps) {
{map.custom_text ? (
<>
<TextView rawText={map.custom_text}></TextView>
<div className='tw:grid'>
<label
className='tw:btn tw:btn-primary tw:place-self-end tw:mt-4'
onClick={() => clickAction1()}
>
Close
</label>
</div>
</>
) : (
<div className='tw:relative'>

View File

@ -51,6 +51,11 @@ interface CustomUserFields {
position: Point
}
interface ItemSecret {
secret: string
item: string
}
export interface MyCollections {
places: Place[]
events: Event[]
@ -58,6 +63,7 @@ export interface MyCollections {
tags: Tag[]
projects: Project[]
directus_users: CustomUserFields[]
item_secrets: ItemSecret[]
items: Item[]
team: any[]
features: any[]

79
app/src/api/inviteApi.ts Normal file
View File

@ -0,0 +1,79 @@
/* @eslint-disable-next-line import/no-relative-parent-imports */
import { config } from '@/config'
import type { UserApi } from 'utopia-ui'
type InvitingProfileResponse = [
{
item: string
},
]
export class InviteApi {
userApi: UserApi
constructor(userApi: UserApi) {
this.userApi = userApi
}
async validateInvite(inviteId: string): Promise<string | null> {
try {
const response = await fetch(
`${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`,
{
method: 'GET',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
},
)
if (!response.ok) return null
const data = (await response.json()) as InvitingProfileResponse
return data[0].item
} catch (error: unknown) {
// eslint-disable-next-line no-console
console.error('Error fetching inviting profile:', error)
if (error instanceof Error && error.message) {
throw new Error(error.message)
} else {
throw new Error('An unknown error occurred while fetching the inviting profile.')
}
}
}
async redeemInvite(inviteId: string, itemId: string): Promise<string | null> {
try {
const token = await this.userApi.getToken()
if (!token) {
throw new Error('User is not authenticated. Cannot redeem invite.')
}
const response = await fetch(`${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}`, {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ secret: inviteId, item: itemId }),
})
if (!response.ok) return null
return (await response.json()) as string
} catch (error: unknown) {
// eslint-disable-next-line no-console
console.error('Error fetching inviting profile:', error)
if (error instanceof Error && error.message) {
throw new Error(error.message)
} else {
throw new Error('An unknown error occurred while fetching the inviting profile.')
}
}
}
}

View File

@ -45,6 +45,7 @@ export class itemsApi<T> implements ItemsApi<T> {
readItems(this.collectionName as never, {
fields: [
'*',
'secrets.*',
'to.*',
'relations.*',
'user_created.*',

View File

@ -8,7 +8,7 @@ import { createUser, passwordRequest, passwordReset, readMe, updateMe } from '@d
import { directusClient } from './directus'
import type { UserApi, UserItem } from 'utopia-ui'
import type { UserItem } from 'utopia-ui'
interface DirectusError {
errors: {
@ -17,7 +17,7 @@ interface DirectusError {
}[]
}
export class userApi implements UserApi {
export class UserApi {
async register(email: string, password: string, userName: string): Promise<any> {
try {
return await directusClient.request(createUser({ email, password, first_name: userName }))

12
app/src/config/index.ts Normal file
View File

@ -0,0 +1,12 @@
export const config = {
apiUrl: String(import.meta.env.VITE_API_URL ?? 'https://api.utopia-lab.org'),
validateInviteFlowId: String(
import.meta.env.VITE_VALIDATE_INVITE_FLOW_ID ?? '01d61db0-25aa-4bfa-bc24-c6a8f208a455',
),
redeemInviteFlowId: String(
import.meta.env.VITE_REDEEM_INVITE_FLOW_ID ?? 'cc80ec73-ecf5-4789-bee5-1127fb1a6ed4',
),
openCollectiveApiKey: String(import.meta.env.VITE_OPEN_COLLECTIVE_API_KEY ?? ''),
}
export type Config = typeof config

View File

@ -84,6 +84,8 @@ function MapContainer({ layers, map }: { layers: LayerProps[]; map: any }) {
defaultTheme={map.default_theme}
showZoomControl={map.show_zoom_control}
expandLayerControl={map.expand_layer_control}
tileServerUrl={map.tile_server_url}
tileServerAttribution={map.tile_server_attribution}
>
{layers &&
apis &&

View File

@ -10,16 +10,40 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"utopia-ui": ["../lib/src"],
"#components/*": ["../lib/src/Components/*"],
"#utils/*": ["../lib/src/Utils/*"],
"#types/*": ["../lib/src/types/*"],
"#assets/*": ["../lib/src/assets/*"],
"#src/*": ["../lib/src/*"],
"#root/*": ["../lib/*"]
"@/*": [
"src/*"
],
"utopia-ui": [
"../lib/src"
],
"#components/*": [
"../lib/src/Components/*"
],
"#utils/*": [
"../lib/src/Utils/*"
],
"#types/*": [
"../lib/src/types/*"
],
"#assets/*": [
"../lib/src/assets/*"
],
"#src/*": [
"../lib/src/*"
],
"#root/*": [
"../lib/*"
]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@ -1,11 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import fs from 'fs';
import path from 'path';
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import fs from 'fs'
import path from 'path'
import tsConfigPaths from 'vite-tsconfig-paths'
// __dirname-Ersatz für ESModules
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const __dirname = path.dirname(new URL(import.meta.url).pathname)
export default defineConfig({
server: {
@ -18,21 +19,9 @@ export default defineConfig({
* },
*/
},
plugins: [
react(),
tailwindcss(),
],
plugins: [react(), tailwindcss(), tsConfigPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom'],
alias: {
'utopia-ui': path.resolve(__dirname, '../lib/src'),
'#components': path.resolve(__dirname, '../lib/src/Components'),
'#utils': path.resolve(__dirname, '../lib/src/Utils'),
'#types': path.resolve(__dirname, '../lib/src/types'),
'#assets': path.resolve(__dirname, '../lib/src/assets'),
'#src': path.resolve(__dirname, '../lib/src'),
'#root': path.resolve(__dirname, '../lib'),
}
},
build: {
sourcemap: true,
@ -44,21 +33,20 @@ export default defineConfig({
}
if (id.includes('node_modules')) {
if (id.includes('react')) {
return 'react';
return 'react'
}
if (id.includes('tiptap')) {
return 'tiptap';
return 'tiptap'
}
if (id.includes('leaflet')) {
return 'leaflet';
return 'leaflet'
}
if (id.includes('lib/node_modules')) {
return 'utopia-ui-vendor'
}
else return 'vendor';
} else return 'vendor'
}
}
},
},
},
},
});
})

20
lib/package-lock.json generated
View File

@ -53,6 +53,7 @@
"react-leaflet-cluster": "^2.1.0",
"react-markdown": "^10.1.0",
"react-photo-album": "^3.0.2",
"react-qr-code": "^2.0.16",
"react-router-dom": "^6.23.0",
"react-toastify": "^9.1.3",
"rehype-raw": "^7.0.0",
@ -11658,6 +11659,12 @@
"node": ">=6"
}
},
"node_modules/qr.js": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
"integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz",
@ -11875,6 +11882,19 @@
}
}
},
"node_modules/react-qr-code": {
"version": "2.0.16",
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.16.tgz",
"integrity": "sha512-8f54aTOo7DxYr1LB47pMeclV5SL/zSbJxkXHIS2a+QnAIa4XDVIdmzYRC+CBCJeDLSCeFHn8gHtltwvwZGJD/w==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1",
"qr.js": "0.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",

View File

@ -141,6 +141,7 @@
"react-leaflet-cluster": "^2.1.0",
"react-markdown": "^10.1.0",
"react-photo-album": "^3.0.2",
"react-qr-code": "^2.0.16",
"react-router-dom": "^6.23.0",
"react-toastify": "^9.1.3",
"rehype-raw": "^7.0.0",

View File

@ -48,6 +48,7 @@ export default [
/node_modules\/markdown-it-task-lists/,
/node_modules\/classnames/,
/node_modules\/html-truncate/,
/node_modules\/react-qr-code/,
],
requireReturnsDefault: 'auto',
}),

View File

@ -1,28 +1,65 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
import { useAuth } from './useAuth'
import type { InviteApi } from '#types/InviteApi'
interface Props {
inviteApi: InviteApi
}
/**
* @category Auth
*/
export function LoginPage() {
export function LoginPage({ inviteApi }: Props) {
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const { login, loading } = useAuth()
const { myProfile, isMyProfileLoaded } = useMyProfile()
const navigate = useNavigate()
// eslint-disable-next-line react-hooks/exhaustive-deps
const onLogin = async () => {
const redeemInvite = useCallback(
async (inviteCode: string): Promise<string | null> => {
if (!isMyProfileLoaded) return null
if (!myProfile) {
toast.error('Could not find your profile to redeem the invite.')
return null
}
const invitingProfileId = await inviteApi.redeemInvite(inviteCode, myProfile.id)
localStorage.removeItem('inviteCode') // Clear invite code after redeeming
return invitingProfileId
},
[inviteApi, isMyProfileLoaded, myProfile],
)
const handleSuccess = useCallback(async () => {
const inviteCode = localStorage.getItem('inviteCode')
let invitingProfileId: string | null = null
if (inviteCode) {
invitingProfileId = await redeemInvite(inviteCode)
}
if (invitingProfileId) {
navigate(`/item/${invitingProfileId}`)
} else {
navigate('/')
}
}, [navigate, redeemInvite])
const onLogin = useCallback(async () => {
await toast.promise(login({ email, password }), {
success: {
render({ data }) {
navigate('/')
void handleSuccess()
return `Hi ${data?.first_name ? data.first_name : 'Traveler'}`
},
// other options
@ -36,7 +73,7 @@ export function LoginPage() {
},
pending: 'logging in ...',
})
}
}, [email, handleSuccess, login, password])
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {

View File

@ -1,10 +1,12 @@
import { createContext, useState, useContext, useEffect } from 'react'
import { createContext, useState, useContext, useEffect, useCallback } from 'react'
import type { InviteApi } from '#types/InviteApi'
import type { UserApi } from '#types/UserApi'
import type { UserItem } from '#types/UserItem'
interface AuthProviderProps {
userApi: UserApi
inviteApi: InviteApi
children?: React.ReactNode
}
@ -16,6 +18,7 @@ interface AuthCredentials {
interface AuthContextProps {
isAuthenticated: boolean
isInitialized: boolean
user: UserItem | null
login: (credentials: AuthCredentials) => Promise<UserItem | undefined>
register: (credentials: AuthCredentials, userName: string) => Promise<UserItem | undefined>
@ -29,6 +32,7 @@ interface AuthContextProps {
const AuthContext = createContext<AuthContextProps>({
isAuthenticated: false,
isInitialized: false,
user: null,
login: () => Promise.reject(Error('Unimplemented')),
register: () => Promise.reject(Error('Unimplemented')),
@ -47,17 +51,10 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
const [user, setUser] = useState<UserItem | null>(null)
const [token, setToken] = useState<string>()
const [loading, setLoading] = useState<boolean>(false)
const [isInitialized, setIsInitialized] = useState<boolean>(false)
const isAuthenticated = !!user
useEffect(() => {
setLoading(true)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadUser()
setLoading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function loadUser(): Promise<UserItem | undefined> {
const loadUser: () => Promise<UserItem | undefined> = useCallback(async () => {
try {
const token = await userApi.getToken()
setToken(token)
@ -66,20 +63,30 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
setUser(me)
setLoading(false)
return me
} else return undefined
} else {
return undefined
}
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
setLoading(false)
return undefined
} finally {
setIsInitialized(true)
}
}
}, [userApi])
useEffect(() => {
void loadUser()
}, [loadUser])
const login = async (credentials: AuthCredentials): Promise<UserItem | undefined> => {
setLoading(true)
try {
const user = await userApi.login(credentials.email, credentials.password)
setToken(user?.access_token)
return await loadUser()
const fullUser = await loadUser()
return fullUser
} catch (error) {
setLoading(false)
throw error
@ -150,6 +157,7 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
<AuthContext.Provider
value={{
isAuthenticated,
isInitialized,
user,
login,
register,

View File

@ -0,0 +1,9 @@
export const InputLabel = ({ label }: { label: string }) => {
return (
<label className='tw:label tw:pb-1'>
<span className='tw:block tw:text-sm tw:font-medium tw:text-base-content/50 tw:mb-1'>
{label}:
</span>
</label>
)
}

View File

@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { InputLabel } from './InputLabel'
interface TextAreaProps {
labelTitle?: string
labelStyle?: string
containerStyle?: string
dataField?: string
inputStyle?: string
@ -18,7 +19,6 @@ interface TextAreaProps {
export function TextAreaInput({
labelTitle,
dataField,
labelStyle,
containerStyle,
inputStyle,
defaultValue,
@ -43,13 +43,7 @@ export function TextAreaInput({
return (
<div className={`tw:form-control tw:w-full ${containerStyle ?? ''}`}>
{labelTitle ? (
<label className='tw:label'>
<span className={`tw:label-text tw:text-base-content ${labelStyle ?? ''}`}>
{labelTitle}
</span>
</label>
) : null}
{labelTitle ? <InputLabel label={labelTitle} /> : null}
<textarea
required={required}
ref={ref}

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { InputLabel } from './InputLabel'
interface InputTextProps {
labelTitle?: string
labelStyle?: string
type?: string
dataField?: string
containerStyle?: string
@ -20,7 +21,6 @@ interface InputTextProps {
*/
export function TextInput({
labelTitle,
labelStyle,
type,
dataField,
containerStyle,
@ -48,13 +48,7 @@ export function TextInput({
return (
<div className={`tw:form-control ${containerStyle ?? ''}`}>
{labelTitle ? (
<label className='tw:label'>
<span className={`tw:label-text tw:text-base-content ${labelStyle ?? ''}`}>
{labelTitle}
</span>
</label>
) : null}
{labelTitle ? <InputLabel label={labelTitle} /> : null}
<input
required={required}
pattern={pattern}

View File

@ -5,12 +5,13 @@ exports[`<TextAreaInput /> > labelTitle > sets label 1`] = `
class="tw:form-control tw:w-full "
>
<label
class="tw:label"
class="tw:label tw:pb-1"
>
<span
class="tw:label-text tw:text-base-content "
class="tw:block tw:text-sm tw:font-medium tw:text-base-content/50 tw:mb-1"
>
My Title
:
</span>
</label>
<textarea

View File

@ -5,12 +5,13 @@ exports[`<TextInput /> > labelTitle > sets label 1`] = `
class="tw:form-control "
>
<label
class="tw:label"
class="tw:label tw:pb-1"
>
<span
class="tw:label-text tw:text-base-content "
class="tw:block tw:text-sm tw:font-medium tw:text-base-content/50 tw:mb-1"
>
My Title
:
</span>
</label>
<input

View File

@ -1,2 +1,4 @@
export { TextAreaInput } from './TextAreaInput'
export { TextInput } from './TextInput'
export { InputLabel } from './InputLabel'
export { RichTextEditor } from './RichTextEditor/RichTextEditor'

View File

@ -6,7 +6,6 @@ import type { Item } from '#types/Item'
export interface StartEndInputProps {
item?: Item
showLabels?: boolean
labelStyle?: string
updateStartValue?: (value: string) => void
updateEndValue?: (value: string) => void
containerStyle?: string
@ -18,7 +17,6 @@ export interface StartEndInputProps {
export const PopupStartEndInput = ({
item,
showLabels = true,
labelStyle,
updateStartValue,
updateEndValue,
containerStyle,
@ -31,7 +29,6 @@ export const PopupStartEndInput = ({
dataField='start'
inputStyle='tw:text-sm tw:px-2'
labelTitle={showLabels ? 'Start' : ''}
labelStyle={labelStyle}
defaultValue={item && item.start ? item.start.substring(0, 10) : ''}
autocomplete='one-time-code'
updateFormValue={updateStartValue}
@ -42,7 +39,6 @@ export const PopupStartEndInput = ({
dataField='end'
inputStyle='tw:text-sm tw:px-2'
labelTitle={showLabels ? 'End' : ''}
labelStyle={labelStyle}
defaultValue={item && item.end ? item.end.substring(0, 10) : ''}
autocomplete='one-time-code'
updateFormValue={updateEndValue}

View File

@ -56,6 +56,8 @@ function UtopiaMap({
defaultTheme,
donationWidget,
expandLayerControl,
tileServerUrl,
tileServerAttribution,
}: {
/** height of the map (default '500px') */
height?: string
@ -85,6 +87,10 @@ function UtopiaMap({
donationWidget?: boolean
/** open layer control on map initialisation */
expandLayerControl?: boolean
/** configure a custom tile server */
tileServerUrl?: string
/** configure a custom tile server attribution */
tileServerAttribution?: string
}) {
return (
<ContextWrapper>
@ -104,6 +110,8 @@ function UtopiaMap({
showThemeControl={showThemeControl}
defaultTheme={defaultTheme}
expandLayerControl={expandLayerControl}
tileServerUrl={tileServerUrl}
tileServerAttribution={tileServerAttribution}
>
{children}
</UtopiaMapInner>

View File

@ -55,6 +55,8 @@ export function UtopiaMapInner({
defaultTheme = '',
donationWidget,
expandLayerControl,
tileServerUrl,
tileServerAttribution,
}: {
children?: React.ReactNode
geo?: GeoJsonObject
@ -65,6 +67,8 @@ export function UtopiaMapInner({
showThemeControl?: boolean
defaultTheme?: string
expandLayerControl?: boolean
tileServerUrl?: string
tileServerAttribution?: string
}) {
const selectNewItemPosition = useSelectPosition()
const setSelectNewItemPosition = useSetSelectPosition()
@ -277,8 +281,11 @@ export function UtopiaMapInner({
</Control>
<TileLayer
maxZoom={19}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://tile.osmand.net/hd/{z}/{x}/{y}.png'
attribution={
tileServerAttribution ??
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}
url={tileServerUrl ?? 'https://tile.osmand.net/hd/{z}/{x}/{y}.png'}
/>
<MarkerClusterGroup
ref={(r) => setClusterRef(r as any)}

View File

@ -0,0 +1,20 @@
import { useAuth } from '#components/Auth/useAuth'
import { useItems, useAllItemsLoaded } from './useItems'
export const useMyProfile = () => {
const items = useItems()
const allItemsLoaded = useAllItemsLoaded()
const user = useAuth().user
// allItemsLoaded is not reliable, so we check if items.length > 0
const isMyProfileLoaded = allItemsLoaded && items.length > 0 && !!user
// Find the user's profile item
const myProfile = items.find(
(item) => item.layer?.userProfileLayer && item.user_created?.id === user?.id,
)
return { myProfile, isMyProfileLoaded }
}

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { useAuth } from '#components/Auth/useAuth'
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
import type { InviteApi } from '#types/InviteApi'
interface Props {
inviteApi: InviteApi
}
/**
* @category Onboarding
*/
export function InvitePage({ inviteApi }: Props) {
const { isAuthenticated, isInitialized: isAuthenticationInitialized } = useAuth()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { myProfile, isMyProfileLoaded } = useMyProfile()
if (!id) throw new Error('Invite ID is required')
useEffect(() => {
async function redeemInvite() {
if (!id) throw new Error('Invite ID is required')
if (!isMyProfileLoaded) return
if (!myProfile) {
toast.error('Could not find your profile to redeem the invite.')
return
}
const invitingProfileId = await inviteApi.redeemInvite(id, myProfile.id)
if (invitingProfileId) {
toast.success('Invite redeemed successfully!')
navigate(`/item/${invitingProfileId}`)
} else {
toast.error('Failed to redeem invite')
navigate('/')
}
}
if (!isAuthenticationInitialized) return
if (isAuthenticated) {
void redeemInvite()
} else {
// Save invite code in local storage
localStorage.setItem('inviteCode', id)
// Redirect to login page
navigate('/login')
}
}, [
id,
isAuthenticated,
inviteApi,
navigate,
isAuthenticationInitialized,
myProfile,
isMyProfileLoaded,
])
return (
<MapOverlayPage backdrop className='tw:max-w-xs tw:h-fit'>
<h2 className='tw:text-2xl tw:font-semibold tw:mb-2 tw:text-center'>Invitation</h2>
</MapOverlayPage>
)
}

View File

@ -0,0 +1 @@
export { InvitePage } from './InvitePage'

View File

@ -1,4 +1,4 @@
import { TextInput } from '#components/Input'
import { InputLabel, TextInput } from '#components/Input'
import type { FormState } from '#types/FormState'
@ -12,12 +12,7 @@ export const ContactInfoForm = ({
return (
<div className='tw:mt-2 tw:space-y-2'>
<div>
<label
htmlFor='email'
className='tw:block tw:text-sm tw:font-medium tw:text-gray-500 tw:mb-1'
>
Email-Adresse (Kontakt):
</label>
<InputLabel label='Email-Adresse (Kontakt)' />
<TextInput
placeholder='Email'
type='email'
@ -33,12 +28,7 @@ export const ContactInfoForm = ({
</div>
<div>
<label
htmlFor='telephone'
className='tw:block tw:text-sm tw:font-medium tw:text-gray-500 tw:mb-1'
>
Telefonnummer (Kontakt):
</label>
<InputLabel label='Telefonnummer (Kontakt)' />
<TextInput
placeholder='Telefonnummer'
type='tel'

View File

@ -1,4 +1,4 @@
import { TextInput } from '#components/Input'
import { TextInput, InputLabel } from '#components/Input'
import type { FormState } from '#types/FormState'
@ -12,12 +12,7 @@ export const CrowdfundingForm = ({
return (
<div className='tw:mt-4 tw:space-y-4'>
<div>
<label
htmlFor='OpenCollectiveSlug'
className='tw:block tw:text-sm tw:font-medium tw:text-gray-500 tw:mb-1'
>
Open Collective Slug:
</label>
<InputLabel label='Open Collective Slug' />
<TextInput
placeholder='Open Collective Slug'
type='text'

View File

@ -5,6 +5,7 @@ import { useDropzone } from 'react-dropzone'
import { BiSolidImage } from 'react-icons/bi'
import { useAppState } from '#components/AppShell/hooks/useAppState'
import { InputLabel } from '#components/Input/InputLabel'
import DialogModal from '#components/Templates/DialogModal'
import { getImageDimensions } from '#utils/getImageDimensions'
@ -13,6 +14,7 @@ import type { FormState } from '#types/FormState'
interface Props {
state: FormState
setState: React.Dispatch<React.SetStateAction<FormState>>
hideInputLabel?: boolean
}
const compressionOptions = {
@ -21,7 +23,7 @@ const compressionOptions = {
useWebWorker: true,
}
export const GalleryForm = ({ state, setState }: Props) => {
export const GalleryForm = ({ state, setState, hideInputLabel = false }: Props) => {
const appState = useAppState()
const [imageSelectedToDelete, setImageSelectedToDelete] = useState<number | null>(null)
@ -96,8 +98,9 @@ export const GalleryForm = ({ state, setState }: Props) => {
}
return (
<>
<div className='tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4 tw:my-4'>
<div className='tw:mt-3'>
{!hideInputLabel && <InputLabel label='Media' />}
<div className='tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4'>
{images.map((image, index) => (
<div key={index} className='tw:relative'>
<img
@ -161,6 +164,6 @@ export const GalleryForm = ({ state, setState }: Props) => {
</div>
</div>
</DialogModal>
</>
</div>
)
}

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { useEffect } from 'react'
import { InputLabel } from '#components/Input'
import ComboBoxInput from '#components/Input/ComboBoxInput'
import type { FormState } from '#types/FormState'
@ -51,12 +52,7 @@ export const GroupSubheaderForm = ({
return (
<div className='tw:grid tw:grid-cols-1 tw:@sm:grid-cols-2 tw:gap-2'>
<div>
<label
htmlFor='status'
className='tw:block tw:text-sm tw:font-medium tw:text-gray-500 tw:mb-1'
>
Gruppenstatus:
</label>
<InputLabel label='Gruppenstatus' />
<ComboBoxInput
id='status'
options={groupStates || []}
@ -70,12 +66,7 @@ export const GroupSubheaderForm = ({
/>
</div>
<div>
<label
htmlFor='groupType'
className='tw:block tw:text-sm tw:font-medium tw:text-gray-500 tw:mb-1'
>
Gruppenart:
</label>
<InputLabel label='Gruppenart' />
<ComboBoxInput
id='groupType'
options={groupTypes?.map((gt) => gt.groupTypes_id.name) || []}

View File

@ -0,0 +1,75 @@
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { InviteLinkView } from './InviteLinkView'
import type { Item } from '#types/Item'
const itemWithSecret: Item = {
secrets: [
{
secret: 'secret1',
},
],
id: '1',
name: 'Test Item',
}
const itemWithoutSecret: Item = {
secrets: [],
id: '2',
name: 'Test Item Without Secret',
}
const itemWithUndefinedSecrets: Item = {
id: '3',
name: 'Test Item With Undefined Secrets',
}
describe('<InviteLinkView />', () => {
let wrapper: ReturnType<typeof render>
const Wrapper = ({ item }: { item: Item }) => {
return render(<InviteLinkView item={item} />)
}
describe('when item does not have secrets', () => {
it('does not render anything', () => {
wrapper = Wrapper({ item: itemWithoutSecret })
expect(wrapper.container.firstChild).toBeNull()
})
})
describe('when item has secrets undefined', () => {
it('does not render anything', () => {
wrapper = Wrapper({ item: itemWithUndefinedSecrets })
expect(wrapper.container.firstChild).toBeNull()
})
})
describe('when item has secrets', () => {
beforeEach(() => {
wrapper = Wrapper({ item: itemWithSecret })
})
it('renders the secret', () => {
expect(wrapper.getByDisplayValue('secret1', { exact: false })).toBeInTheDocument()
})
it('matches the snapshot', () => {
expect(wrapper.container.firstChild).toMatchSnapshot()
})
it('copies the secret to clipboard when button is clicked', () => {
const copyButton = wrapper.getByRole('button')
expect(copyButton).toBeInTheDocument()
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText')
fireEvent.click(copyButton)
// TODO Implement in a way that the URL stays consistent on CI
expect(clipboardSpy).toHaveBeenCalledWith('http://localhost:3000/invite/secret1')
})
})
})

View File

@ -0,0 +1,38 @@
import { ClipboardIcon } from '@heroicons/react/24/outline'
import QRCode from 'react-qr-code'
import { toast } from 'react-toastify'
import type { Item } from '#types/Item'
export const InviteLinkView = ({ item }: { item: Item }) => {
// Only show if user has permission to view secrets.
if (!item.secrets || item.secrets.length === 0) return
const link = `${window.location.origin}/invite/${item.secrets[0].secret}`
const copyToClipboard = () => {
void navigator.clipboard
.writeText(link)
.then(() => toast.success('Invite link copied to clipboard!'))
}
return (
<div className='tw:my-10 tw:mt-2 tw:px-6'>
<h2 className='tw:text-lg tw:font-semibold'>Invite</h2>
<div className='tw:mt-2 tw:text-sm tw:flex tw:gap-2 tw:mb-2'>
<input
type='text'
value={link}
readOnly
className='tw:w-full tw:p-2 tw:border tw:rounded'
/>
<button onClick={copyToClipboard} className='btn btn-circle btn-primary'>
<ClipboardIcon className='w-6 h-6' />
</button>
</div>
<div className='tw:bg-white tw:p-2 tw:w-fit'>
<QRCode value={link} size={128} />
</div>
</div>
)
}

View File

@ -4,7 +4,7 @@
import { useEffect, useState } from 'react'
import { RichTextEditor } from '#components/Input/RichTextEditor/RichTextEditor'
import { InputLabel, RichTextEditor } from '#components/Input'
import { MarkdownHint } from './MarkdownHint'
@ -13,11 +13,9 @@ import type { FormState } from '#types/FormState'
export const ProfileTextForm = ({
state,
setState,
// Is this really used?
dataField,
heading,
size,
hideInputLabel,
}: {
state: FormState
setState: React.Dispatch<React.SetStateAction<FormState>>
@ -36,15 +34,10 @@ export const ProfileTextForm = ({
return (
<div
className={`tw:max-h-124 tw:md:max-h-full tw:flex tw:flex-col tw:mt-2 ${size === 'full' ? 'tw:flex-1 tw:min-h-42' : 'tw:h-28 tw:flex-none'}`}
className={`tw:max-h-124 tw:md:max-h-full tw:flex tw:flex-col tw:mt-3 ${size === 'full' ? 'tw:flex-1 tw:min-h-42' : 'tw:h-30 tw:flex-none'}`}
>
<div className='tw:flex tw:justify-between tw:items-center'>
<label
htmlFor='nextAppointment'
className='tw:block tw:text-sm tw:font-medium tw:text-base-content/50 tw:mb-1'
>
{heading || 'Text'}:
</label>
<InputLabel label={heading || 'Text'} />
<MarkdownHint />
</div>
<RichTextEditor
@ -58,7 +51,6 @@ export const ProfileTextForm = ({
}))
}
showMenu={size === 'full'}
labelStyle={hideInputLabel ? 'tw:hidden' : ''}
containerStyle={size === 'full' ? 'tw:flex-1' : 'tw:h-24 tw:flex-none'}
/>
</div>

View File

@ -0,0 +1,37 @@
import { useItems } from '#components/Map/hooks/useItems'
import type { Item } from '#types/Item'
interface Props {
item: Item
relation: string
}
export const RelationsView = ({ item, relation }: Props) => {
const items = useItems()
if (!item.relations) return
const relationsOfRightType = item.relations.filter((r) => r.type === relation)
const relatedItems = items.filter((i) => relationsOfRightType.some((r) => r.id === i.id))
const hasRelatedItems = relatedItems.length > 0
return (
<div>
<h2>{relation}</h2>
{hasRelatedItems ? (
<ul>
{relatedItems.map((relatedItem) => (
<li key={relatedItem.id}>
<a href={`/item/${relatedItem.id}`}>{relatedItem.name}</a>
</li>
))}
</ul>
) : (
<p>No related items found.</p>
)}
</div>
)
}

View File

@ -3,99 +3,113 @@
exports[`GalleryForm > with previous images > renders 1`] = `
<div>
<div
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4 tw:my-4"
class="tw:mt-3"
>
<div
class="tw:relative"
<label
class="tw:label tw:pb-1"
>
<img
alt="Gallery image 1"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg "
src="undefined1.jpg"
/>
<button
class="tw:m-2 tw:bg-red-500 tw:text-white tw:p-2 tw:rounded-full tw:absolute tw:top-0 tw:right-0 tw:hover:bg-red-600 tw:cursor-pointer"
type="button"
<span
class="tw:block tw:text-sm tw:font-medium tw:text-base-content/50 tw:mb-1"
>
<svg
aria-hidden="true"
class="tw:h-5 tw:w-5"
data-slot="icon"
data-testid="trash"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z"
fill-rule="evenodd"
/>
</svg>
</button>
</div>
Media
:
</span>
</label>
<div
class="tw:relative"
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4"
>
<img
alt="Gallery image 2"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg "
src="undefined2.jpg"
/>
<button
class="tw:m-2 tw:bg-red-500 tw:text-white tw:p-2 tw:rounded-full tw:absolute tw:top-0 tw:right-0 tw:hover:bg-red-600 tw:cursor-pointer"
type="button"
<div
class="tw:relative"
>
<svg
aria-hidden="true"
class="tw:h-5 tw:w-5"
data-slot="icon"
data-testid="trash"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<img
alt="Gallery image 1"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg "
src="undefined1.jpg"
/>
<button
class="tw:m-2 tw:bg-red-500 tw:text-white tw:p-2 tw:rounded-full tw:absolute tw:top-0 tw:right-0 tw:hover:bg-red-600 tw:cursor-pointer"
type="button"
>
<path
clip-rule="evenodd"
d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z"
fill-rule="evenodd"
/>
</svg>
</button>
</div>
<div
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
role="presentation"
tabindex="0"
>
<input
accept="image/jpeg,image/png"
data-testid="gallery-upload-input"
multiple=""
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
tabindex="-1"
type="file"
/>
<div>
<svg
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
fill="currentColor"
height="1em"
stroke="currentColor"
stroke-width="0"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<svg
aria-hidden="true"
class="tw:h-5 tw:w-5"
data-slot="icon"
data-testid="trash"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z"
fill-rule="evenodd"
/>
</svg>
</button>
</div>
<div
class="tw:relative"
>
<img
alt="Gallery image 2"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg "
src="undefined2.jpg"
/>
<button
class="tw:m-2 tw:bg-red-500 tw:text-white tw:p-2 tw:rounded-full tw:absolute tw:top-0 tw:right-0 tw:hover:bg-red-600 tw:cursor-pointer"
type="button"
>
<path
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
/>
</svg>
<span
class="tw:text-center"
>
Upload Image
</span>
<svg
aria-hidden="true"
class="tw:h-5 tw:w-5"
data-slot="icon"
data-testid="trash"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z"
fill-rule="evenodd"
/>
</svg>
</button>
</div>
<div
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
role="presentation"
tabindex="0"
>
<input
accept="image/jpeg,image/png"
data-testid="gallery-upload-input"
multiple=""
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
tabindex="-1"
type="file"
/>
<div>
<svg
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
fill="currentColor"
height="1em"
stroke="currentColor"
stroke-width="0"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
/>
</svg>
<span
class="tw:text-center"
>
Upload Image
</span>
</div>
</div>
</div>
</div>
@ -105,77 +119,91 @@ exports[`GalleryForm > with previous images > renders 1`] = `
exports[`GalleryForm > with uploading images > renders 1`] = `
<div>
<div
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4 tw:my-4"
class="tw:mt-3"
>
<div
class="tw:relative"
<label
class="tw:label tw:pb-1"
>
<img
alt="Gallery image 1"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
src="blob-url-placeholder"
/>
<span
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
/>
</div>
class="tw:block tw:text-sm tw:font-medium tw:text-base-content/50 tw:mb-1"
>
Media
:
</span>
</label>
<div
class="tw:relative"
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4"
>
<img
alt="Gallery image 2"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
src="blob-url-placeholder"
/>
<span
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
/>
</div>
<div
class="tw:relative"
>
<img
alt="Gallery image 3"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
src="blob-url-placeholder"
/>
<span
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
/>
</div>
<div
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
role="presentation"
tabindex="0"
>
<input
accept="image/jpeg,image/png"
data-testid="gallery-upload-input"
multiple=""
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
tabindex="-1"
type="file"
/>
<div>
<svg
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
fill="currentColor"
height="1em"
stroke="currentColor"
stroke-width="0"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
/>
</svg>
<div
class="tw:relative"
>
<img
alt="Gallery image 1"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
src="blob-url-placeholder"
/>
<span
class="tw:text-center"
>
Upload Image
</span>
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
/>
</div>
<div
class="tw:relative"
>
<img
alt="Gallery image 2"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
src="blob-url-placeholder"
/>
<span
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
/>
</div>
<div
class="tw:relative"
>
<img
alt="Gallery image 3"
class="tw:w-full tw:h-full tw:object-cover tw:rounded-lg tw:opacity-50"
src="blob-url-placeholder"
/>
<span
class="tw:loading tw:loading-spinner tw:absolute tw:inset-0 tw:m-auto"
/>
</div>
<div
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
role="presentation"
tabindex="0"
>
<input
accept="image/jpeg,image/png"
data-testid="gallery-upload-input"
multiple=""
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
tabindex="-1"
type="file"
/>
<div>
<svg
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
fill="currentColor"
height="1em"
stroke="currentColor"
stroke-width="0"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
/>
</svg>
<span
class="tw:text-center"
>
Upload Image
</span>
</div>
</div>
</div>
</div>
@ -185,41 +213,55 @@ exports[`GalleryForm > with uploading images > renders 1`] = `
exports[`GalleryForm > without previous images > renders 1`] = `
<div>
<div
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4 tw:my-4"
class="tw:mt-3"
>
<div
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
role="presentation"
tabindex="0"
<label
class="tw:label tw:pb-1"
>
<input
accept="image/jpeg,image/png"
data-testid="gallery-upload-input"
multiple=""
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
tabindex="-1"
type="file"
/>
<div>
<svg
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
fill="currentColor"
height="1em"
stroke="currentColor"
stroke-width="0"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
/>
</svg>
<span
class="tw:text-center"
>
Upload Image
</span>
<span
class="tw:block tw:text-sm tw:font-medium tw:text-base-content/50 tw:mb-1"
>
Media
:
</span>
</label>
<div
class="tw:grid tw:grid-cols-2 tw:@md:grid-cols-3 tw:@lg:grid-cols-4 tw:gap-4"
>
<div
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-base-content/50 tw:w-full tw:h-full tw:cursor-pointer tw:card tw:card-body tw:border tw:border-current/50 tw:border-dashed tw:bg-base-200"
role="presentation"
tabindex="0"
>
<input
accept="image/jpeg,image/png"
data-testid="gallery-upload-input"
multiple=""
style="border: 0px; clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; margin: 0px -1px -1px 0px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
tabindex="-1"
type="file"
/>
<div>
<svg
class="tw:h-16 tw:w-16 tw:m-auto tw:mb-2"
fill="currentColor"
height="1em"
stroke="currentColor"
stroke-width="0"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-13.5 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm5.5 10h-7l4-5 1.5 2 3-4 5.5 7h-7z"
/>
</svg>
<span
class="tw:text-center"
>
Upload Image
</span>
</div>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,7 @@ const componentMap = {
startEnd: ProfileStartEndForm,
crowdfundings: CrowdfundingForm,
gallery: GalleryForm,
inviteLinks: () => null, // Not needed for now
// weitere Komponenten hier
}

View File

@ -4,8 +4,10 @@ import { ContactInfoView } from '#components/Profile/Subcomponents/ContactInfoVi
import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView'
import { GalleryView } from '#components/Profile/Subcomponents/GalleryView'
import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView'
import { InviteLinkView } from '#components/Profile/Subcomponents/InviteLinkView'
import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView'
import { ProfileTextView } from '#components/Profile/Subcomponents/ProfileTextView'
import { RelationsView } from '#components/Profile/Subcomponents/RelationsView'
import type { Item } from '#types/Item'
import type { Key } from 'react'
@ -17,6 +19,8 @@ const componentMap = {
startEnd: ProfileStartEndView,
gallery: GalleryView,
crowdfundings: CrowdfundingView,
inviteLinks: InviteLinkView,
relations: RelationsView,
// weitere Komponenten hier
}

View File

@ -44,7 +44,6 @@ export const TabsForm = ({
<PopupStartEndInput
item={item}
showLabels={true}
labelStyle={'tw:text-base-content/50'}
updateEndValue={(e) =>
setState((prevState) => ({
...prevState,
@ -62,7 +61,6 @@ export const TabsForm = ({
<RichTextEditor
labelTitle='About'
labelStyle={'tw:text-base-content/50'}
placeholder='about ...'
defaultValue={item?.text ? item.text : ''}
updateFormValue={(v) =>

View File

@ -1,6 +1,29 @@
.leaflet-control-attribution {
display: none;
.leaflet-control-attribution{
color: #000 !important;
background-color: white/50 !important;
border-radius: 4px;
padding: 0 4px;
z-index: 400 !important;
font-size: 10px;
opacity: 0.5;
}
.leaflet-control-attribution a{
color: #000 !important;
}
.leaflet-attribution-flag {
display: none !important;
}
.leaflet-control-attribution a:not([href*="openstreetmap"]) {
display: none !important;
}
/* 2) Entfernt den Trenner “|” (liegt im span[aria-hidden]) */
.leaflet-control-attribution span[aria-hidden="true"] {
display: none !important;
}
.leaflet-control-locate {
display: none;

View File

@ -8,6 +8,7 @@ export * from './Components/Gaming'
export * from './Components/Templates'
export * from './Components/Input'
export * from './Components/Item'
export * from './Components/Onboarding'
export * from './Components/Profile'
declare global {

4
lib/src/types/InviteApi.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export interface InviteApi {
validateInvite(inviteId: string): Promise<string | null>
redeemInvite(inviteId: string, itemId: string): Promise<string | null>
}

View File

@ -18,6 +18,10 @@ interface GalleryItem {
| string
}
interface ItemSecret {
secret: string
}
/**
* @category Types
*/
@ -55,6 +59,7 @@ export interface Item {
next_appointment?: string
gallery?: GalleryItem[]
openCollectiveSlug?: string
secrets?: ItemSecret[]
// {
// coordinates: [number, number]

View File

@ -18,4 +18,6 @@ export interface UtopiaMapProps {
donationWidget?: boolean
defaultTheme?: string
expandLayerControl?: boolean
tileServerUrl?: string
tileServerAttribution?: string
}