diff --git a/.github/workflows/test.build.lib.yml b/.github/workflows/test.build.lib.yml index 00e82f3b..f47d03b2 100644 --- a/.github/workflows/test.build.lib.yml +++ b/.github/workflows/test.build.lib.yml @@ -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: diff --git a/app/.env b/app/.env index cb285291..4d364d32 100644 --- a/app/.env +++ b/app/.env @@ -1 +1,4 @@ -VITE_OPEN_COLLECTIVE_API_KEY=your_key \ No newline at end of file +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 diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs index 287f4b5d..af43f19b 100644 --- a/app/.eslintrc.cjs +++ b/app/.eslintrc.cjs @@ -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', diff --git a/app/.gitignore b/app/.gitignore index b9470778..3bdd52eb 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +.DS_Store diff --git a/app/package-lock.json b/app/package-lock.json index 7206aac4..6bb8e152 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index 1f1ca1d3..1c6e9e38 100644 --- a/app/package.json +++ b/app/package.json @@ -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": { diff --git a/app/src/App.tsx b/app/src/App.tsx index 87ba5f10..9999d971 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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() @@ -140,12 +146,12 @@ function App() { if (map && layers) return (
- + }> - } /> + } /> + } /> } /> +
+ +
) : (
diff --git a/app/src/api/directus.ts b/app/src/api/directus.ts index c49a10f5..7b32f8b4 100644 --- a/app/src/api/directus.ts +++ b/app/src/api/directus.ts @@ -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[] diff --git a/app/src/api/inviteApi.ts b/app/src/api/inviteApi.ts new file mode 100644 index 00000000..7a6d2c24 --- /dev/null +++ b/app/src/api/inviteApi.ts @@ -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 { + 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 { + 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.') + } + } + } +} diff --git a/app/src/api/itemsApi.ts b/app/src/api/itemsApi.ts index 854734ab..defb7c99 100644 --- a/app/src/api/itemsApi.ts +++ b/app/src/api/itemsApi.ts @@ -45,6 +45,7 @@ export class itemsApi implements ItemsApi { readItems(this.collectionName as never, { fields: [ '*', + 'secrets.*', 'to.*', 'relations.*', 'user_created.*', diff --git a/app/src/api/userApi.ts b/app/src/api/userApi.ts index 8876e02f..c63e1baa 100644 --- a/app/src/api/userApi.ts +++ b/app/src/api/userApi.ts @@ -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 { try { return await directusClient.request(createUser({ email, password, first_name: userName })) diff --git a/app/src/config/index.ts b/app/src/config/index.ts new file mode 100644 index 00000000..28ab494c --- /dev/null +++ b/app/src/config/index.ts @@ -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 diff --git a/app/src/pages/MapContainer.tsx b/app/src/pages/MapContainer.tsx index 14d84d79..f5bb3a63 100644 --- a/app/src/pages/MapContainer.tsx +++ b/app/src/pages/MapContainer.tsx @@ -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 && diff --git a/app/tsconfig.json b/app/tsconfig.json index 5469587c..95a0cf49 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -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" + } + ] } diff --git a/app/vite.config.ts b/app/vite.config.ts index 0bb7f880..2be6ed48 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -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' } - } + }, }, }, }, -}); +}) diff --git a/lib/package-lock.json b/lib/package-lock.json index 242c740f..66788861 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -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", diff --git a/lib/package.json b/lib/package.json index 7261b958..cc55477e 100644 --- a/lib/package.json +++ b/lib/package.json @@ -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", diff --git a/lib/rollup.config.js b/lib/rollup.config.js index 5094c7d7..71e9a53b 100644 --- a/lib/rollup.config.js +++ b/lib/rollup.config.js @@ -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', }), diff --git a/lib/src/Components/Auth/LoginPage.tsx b/lib/src/Components/Auth/LoginPage.tsx index b3a835a1..a87bdd01 100644 --- a/lib/src/Components/Auth/LoginPage.tsx +++ b/lib/src/Components/Auth/LoginPage.tsx @@ -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('') const [password, setPassword] = useState('') 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 => { + 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) => { diff --git a/lib/src/Components/Auth/useAuth.tsx b/lib/src/Components/Auth/useAuth.tsx index 83c416b0..413e88f5 100644 --- a/lib/src/Components/Auth/useAuth.tsx +++ b/lib/src/Components/Auth/useAuth.tsx @@ -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 register: (credentials: AuthCredentials, userName: string) => Promise @@ -29,6 +32,7 @@ interface AuthContextProps { const AuthContext = createContext({ 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(null) const [token, setToken] = useState() const [loading, setLoading] = useState(false) + const [isInitialized, setIsInitialized] = useState(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 { + const loadUser: () => Promise = 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 => { 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) => { { + return ( + + ) +} diff --git a/lib/src/Components/Input/TextAreaInput.tsx b/lib/src/Components/Input/TextAreaInput.tsx index 2ab53264..6a880b84 100644 --- a/lib/src/Components/Input/TextAreaInput.tsx +++ b/lib/src/Components/Input/TextAreaInput.tsx @@ -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 (
- {labelTitle ? ( - - ) : null} + {labelTitle ? : null}