diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index b60d37ec..00000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-version: 2.1
-orbs:
- browser-tools: circleci/browser-tools@1.1.1
-jobs:
- build:
- docker:
- - image: cimg/node:14.17-browsers
- environment:
- JOBS: 2
- DBUS_SYSTEM_BUS_ADDRESS: unix:path=/host/run/dbus/system_bus_socket
- working_directory: ~/otb-admin-interface
- steps:
- - browser-tools/install-chrome
- - browser-tools/install-chromedriver
- - checkout
- - run:
- name: NPM Install
- command: npm install --non-interactive
- - run: echo 'export PATH=~/otb-admin-interface/node_modules/.bin:$PATH' >> $BASH_ENV
- - run:
- name: Run Tests
- command: ember t
- # - deploy:
- # command: |
- # if [ "${CIRCLE_BRANCH}" == "master" ]; then
- # ember deploy production
- # fi
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..9b8d5147
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,4 @@
+.react-router
+build
+node_modules
+README.md
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index c35a0024..00000000
--- a/.editorconfig
+++ /dev/null
@@ -1,19 +0,0 @@
-# EditorConfig helps developers define and maintain consistent
-# coding styles between different editors and IDEs
-# editorconfig.org
-
-root = true
-
-[*]
-end_of_line = lf
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
-indent_style = space
-indent_size = 2
-
-[*.hbs]
-insert_final_newline = false
-
-[*.{diff,md}]
-trim_trailing_whitespace = false
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index 72df3730..00000000
--- a/.eslintignore
+++ /dev/null
@@ -1,20 +0,0 @@
-# unconventional js
-/blueprints/*/files/
-/vendor/
-
-# compiled output
-/dist/
-/tmp/
-
-# dependencies
-/bower_components/
-/node_modules/
-
-# misc
-/coverage/
-!.*
-
-# ember-try
-/.node_modules.ember-try/
-/bower.json.ember-try
-/package.json.ember-try
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644
index bc0b90f7..00000000
--- a/.eslintrc.js
+++ /dev/null
@@ -1,51 +0,0 @@
- 'use strict';
-
-module.exports = {
- root: true,
- parser: 'babel-eslint',
- parserOptions: {
- ecmaVersion: 2018,
- sourceType: 'module',
- ecmaFeatures: {
- legacyDecorators: true
- }
- },
- plugins: [
- 'ember',
- 'prettier'
- ],
- extends: [
- 'plugin:ember/recommended'
- ],
- env: {
- browser: true
- },
- rules: {
- 'ember/no-jquery': 'error',
- 'no-console': ["error", { allow: ["warn", "error"] }],
- 'semi': "error"
- },
- overrides: [
- // node files
- {
- files: [
- '.eslintrc.js',
- '.template-lintrc.js',
- 'ember-cli-build.js',
- 'testem.js',
- 'blueprints/*/index.js',
- 'config/**/*.js',
- 'lib/*/index.js',
- 'server/**/*.js'
- ],
- parserOptions: {
- sourceType: 'script'
- },
- env: {
- browser: false,
- node: true
- },
- plugins: ['node']
- }
- ]
-};
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..a2d7d118
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,57 @@
+name: Deploy
+
+on:
+ workflow_run:
+ workflows: ["Test"]
+ types: [completed]
+ branches: [main, develop]
+
+jobs:
+ deploy:
+ if: ${{ github.event.workflow_run.conclusion == 'success' }}
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ role-to-assume: ${{ secrets.AWS_ROLE }}
+ aws-region: us-east-1
+
+ - name: Log in to Amazon ECR
+ uses: aws-actions/amazon-ecr-login@v2
+
+ - name: Set deploy environment
+ id: env
+ run: |
+ if [ "${{ github.event.workflow_run.head_branch }}" = "main" ]; then
+ echo "tag=stable" >> "$GITHUB_OUTPUT"
+ echo "service=${{ vars.PROD_ECS_SERVICE }}" >> "$GITHUB_OUTPUT"
+ else
+ echo "tag=latest" >> "$GITHUB_OUTPUT"
+ echo "service=${{ vars.DEV_ECS_SERVICE }}" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Build and push Docker image
+ run: |
+ IMAGE=310867200447.dkr.ecr.us-east-1.amazonaws.com/otb_admin
+ docker build \
+ --platform linux/amd64 \
+ -t otb_admin \
+ --no-cache \
+ .
+ docker tag otb_admin "$IMAGE:${{ steps.env.outputs.tag }}"
+ docker push "$IMAGE:${{ steps.env.outputs.tag }}"
+
+ - name: Force ECS redeployment
+ run: |
+ aws ecs update-service \
+ --cluster otb-admin \
+ --service "${{ steps.env.outputs.service }}" \
+ --force-new-deployment \
+ --region us-east-1
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..df007ad7
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,23 @@
+name: Test
+
+on:
+ push:
+ branches: ["**"]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test -- --run
diff --git a/.gitignore b/.gitignore
index 16b80f31..305a0fb7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,40 +1,8 @@
-*.DS_Store
-.coverage
-tmp
-.vscode
-*.env*
-*server.js
-
-# compiled output
-dist/
-tmp/
-
-# dependencies
-bower_components/
-*node_modules*
-
-# misc
-*/.sass-cache
-*/connect.lock
-*/coverage/
-*/libpeerconnection.log
-*/npm-debug.log*
-*/testem.log
-*/yarn-error.log
-/.env*
-/.pnp*
-/.sass-cache
-/connect.lock
-/coverage/
-/libpeerconnection.log
-/npm-debug.log*
-/testem.log
-/yarn-error.log
-
-# ember-try
-*/.node_modules.ember-try/
-*/bower.json.ember-try
-*/package.json.ember-try
-*.log
-
-.ember-cli.js
\ No newline at end of file
+.DS_Store
+.env
+/node_modules/
+
+# React Router
+/.react-router/
+/build/
+*.pem
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
deleted file mode 100644
index 9a0c3d3f..00000000
--- a/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-v14.15.4
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index 19c202a5..00000000
--- a/.prettierrc
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "singleQuote": true,
- "semi": true
-}
-
diff --git a/.sass-lint.yml b/.sass-lint.yml
deleted file mode 100644
index 37a11603..00000000
--- a/.sass-lint.yml
+++ /dev/null
@@ -1,169 +0,0 @@
-linters:
-
- BangFormat:
- enabled: true
- space_before_bang: true
- space_after_bang: false
-
- BorderZero:
- enabled: true
-
- ColorKeyword:
- enabled: true
-
- Comment:
- enabled: false
-
- DebugStatement:
- enabled: true
-
- DeclarationOrder:
- enabled: true
-
- DuplicateProperty:
- enabled: true
-
- ElsePlacement:
- enabled: true
- style: same_line
-
- EmptyLineBetweenBlocks:
- enabled: true
- ignore_single_line_blocks: false
-
- EmptyRule:
- enabled: true
-
- FinalNewline:
- enabled: true
- present: true
-
- HexLength:
- enabled: true
- style: short
-
- HexNotation:
- enabled: true
- style: lowercase
-
- HexValidation:
- enabled: true
-
- IdSelector:
- enabled: true
-
- ImportPath:
- enabled: true
- leading_underscore: false
- filename_extension: false
-
- Indentation:
- enabled: true
- character: space
- width: 2
-
- LeadingZero:
- enabled: true
- style: include_zero
-
- MergeableSelector:
- enabled: false
-
- NameFormat:
- enabled: true
- convention: hyphenated_lowercase
-
- NestingDepth:
- enabled: true
- max_depth: 3
-
- NoTransitionAll:
- enabled: false
-
- PlaceholderInExtend:
- enabled: true
-
- PropertySortOrder:
- enabled: false
-
- PropertySpelling:
- enabled: true
- extra_properties: []
-
- QualifyingElement:
- enabled: true
- allow_element_with_attribute: false
- allow_element_with_class: false
- allow_element_with_id: false
-
- SelectorDepth:
- enabled: true
- max_depth: 3
-
- SelectorFormat:
- enabled: true
- convention: hyphenated_lowercase
- class_convention: '^(?:u|is|has)\-[a-z][a-zA-Z0-9]*$|^(?!u|is|has)[a-zA-Z][a-zA-Z0-9]*(?:\-[a-z][a-zA-Z0-9]*)?(?:\-\-[a-z][a-zA-Z0-9]*)?$'
-
- Shorthand:
- enabled: true
-
- SingleLinePerProperty:
- enabled: true
- allow_single_line_rule_sets: false
-
- SingleLinePerSelector:
- enabled: true
-
- SpaceAfterComma:
- enabled: true
-
- SpaceAfterPropertyColon:
- enabled: true
- style: one_space
-
- SpaceAfterPropertyName:
- enabled: true
-
- SpaceBeforeBrace:
- enabled: true
- style: space
- allow_single_line_padding: true
-
- SpaceBetweenParens:
- enabled: true
- spaces: 0
-
- StringQuotes:
- enabled: true
- style: single_quotes
-
- TrailingSemicolon:
- enabled: true
-
- TrailingZero:
- enabled: true
-
- UnnecessaryMantissa:
- enabled: true
-
- UnnecessaryParentReference:
- enabled: false
-
- UrlFormat:
- enabled: false
-
- UrlQuotes:
- enabled: true
-
- VendorPrefixes:
- enabled: true
- identifier_list: base
- include: []
- exclude: []
-
- ZeroUnit:
- enabled: true
-
- Compass::*:
- enabled: false
diff --git a/.template-lintrc.js b/.template-lintrc.js
deleted file mode 100644
index f3873700..00000000
--- a/.template-lintrc.js
+++ /dev/null
@@ -1,5 +0,0 @@
-'use strict';
-
-module.exports = {
- extends: 'octane'
-};
diff --git a/.watchmanconfig b/.watchmanconfig
deleted file mode 100644
index 2b71b98e..00000000
--- a/.watchmanconfig
+++ /dev/null
@@ -1 +0,0 @@
-{"ignore_dirs":["tmp","dist","ember-cordova","corber"]}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..a36a9541
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,7 @@
+FROM node:24.15.0-slim
+WORKDIR /src
+COPY . .
+RUN npm install
+RUN npm run build
+EXPOSE 4200
+ENTRYPOINT ["npm", "run", "start"]
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..5c4780a2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,87 @@
+# Welcome to React Router!
+
+A modern, production-ready template for building full-stack React applications using React Router.
+
+[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
+
+## Features
+
+- 🚀 Server-side rendering
+- ⚡️ Hot Module Replacement (HMR)
+- 📦 Asset bundling and optimization
+- 🔄 Data loading and mutations
+- 🔒 TypeScript by default
+- 🎉 TailwindCSS for styling
+- 📖 [React Router docs](https://reactrouter.com/)
+
+## Getting Started
+
+### Installation
+
+Install the dependencies:
+
+```bash
+npm install
+```
+
+### Development
+
+Start the development server with HMR:
+
+```bash
+npm run dev
+```
+
+Your application will be available at `http://localhost:5173`.
+
+## Building for Production
+
+Create a production build:
+
+```bash
+npm run build
+```
+
+## Deployment
+
+### Docker Deployment
+
+To build and run using Docker:
+
+```bash
+docker build -t my-app .
+
+# Run the container
+docker run -p 3000:3000 my-app
+```
+
+The containerized application can be deployed to any platform that supports Docker, including:
+
+- AWS ECS
+- Google Cloud Run
+- Azure Container Apps
+- Digital Ocean App Platform
+- Fly.io
+- Railway
+
+### DIY Deployment
+
+If you're familiar with deploying Node applications, the built-in app server is production-ready.
+
+Make sure to deploy the output of `npm run build`
+
+```
+├── package.json
+├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
+├── build/
+│ ├── client/ # Static assets
+│ └── server/ # Server-side code
+```
+
+## Styling
+
+This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
+
+---
+
+Built with ❤️ using React Router.
diff --git a/app/adapters/application.js b/app/adapters/application.js
deleted file mode 100644
index ce1dc5aa..00000000
--- a/app/adapters/application.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import JSONAPIAdapter from '@ember-data/adapter/json-api';
-import { inject as service } from '@ember/service';
-// import { computed } from '@ember/object';
-import ENV from '../config/environment';
-
-export default class Application extends JSONAPIAdapter {
- @service tenant;
-
-
- // @computed
- get host() {
- if (!this.tenant.tenant) {
- this.tenant.setTenant();
- }
- return `${ENV.APP.API_HOST}/${this.tenant.tenant}`;
- }
-
- ajaxOptions(/*defaultOptions, adapter*/) {
- const options = super.ajaxOptions(...arguments);
- options.credentials = 'include';
-
- return options;
- }
-
- // set headers(v) {
- // return v
- // }
-}
diff --git a/app/adapters/public.js b/app/adapters/public.js
deleted file mode 100644
index 8e3499ca..00000000
--- a/app/adapters/public.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import JSONAPIAdapter from '@ember-data/adapter/json-api';
-import ENV from '../config/environment';
-
-export default class Public extends JSONAPIAdapter {
- host = `${ENV.APP.API_HOST}/public`;
-}
diff --git a/app/adapters/theme.js.bk b/app/adapters/theme.js.bk
deleted file mode 100644
index d1519dd0..00000000
--- a/app/adapters/theme.js.bk
+++ /dev/null
@@ -1,3 +0,0 @@
-import Public from './public';
-
-export default class Theme extends Public {}
diff --git a/app/adapters/tour-set.js b/app/adapters/tour-set.js
deleted file mode 100644
index 94581813..00000000
--- a/app/adapters/tour-set.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import Public from './public';
-
-export default class TourSet extends Public {
- ajaxOptions(/*defaultOptions, adapter*/) {
- const options = super.ajaxOptions(...arguments);
- options.credentials = 'include';
-
- return options;
- }
-}
diff --git a/app/adapters/user.js b/app/adapters/user.js
deleted file mode 100644
index 8c62044f..00000000
--- a/app/adapters/user.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Public from './public';
-
-export default class User extends Public {
- ajaxOptions(/*defaultOptions, adapter*/) {
- const options = super.ajaxOptions(...arguments);
- options.credentials = 'include';
-
- return options;
- }
-
- shouldReloadAll() {
- return true;
- }
-}
diff --git a/app/app.css b/app/app.css
new file mode 100644
index 00000000..49f396af
--- /dev/null
+++ b/app/app.css
@@ -0,0 +1,56 @@
+@import "tailwindcss";
+
+@theme {
+ --font-sans:
+ "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
+ "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+}
+
+@layer base {
+
+ ul,
+ ol {
+ list-style: revert;
+ /* Reverts to the browser's default list style */
+ /* or you can specify a value like: list-style-type: disc; */
+ }
+}
+
+html,
+body {
+ /* @apply bg-white dark:bg-gray-950;
+
+ @media (prefers-color-scheme: dark) {
+ color-scheme: dark;
+ } */
+
+ .jodit-wysiwyg p {
+ margin-bottom: 1rem;
+ }
+
+ .jodit-wysiwyg ul,
+ .jodit-wysiwyg ol {
+ margin: 1rem;
+ }
+
+ nav ul {
+ list-style: none;
+ }
+
+ details summary::-webkit-details-marker {
+ display: none;
+ }
+
+ details summary {
+ list-style: none;
+ }
+
+ button,
+ label {
+ cursor: pointer;
+ }
+
+ button:disabled {
+ cursor: not-allowed;
+ }
+}
\ No newline at end of file
diff --git a/app/app.js b/app/app.js
deleted file mode 100644
index d8e2088b..00000000
--- a/app/app.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Application from '@ember/application';
-import Resolver from 'ember-resolver';
-import loadInitializers from 'ember-load-initializers';
-import config from './config/environment';
-
-export default class App extends Application {
- modulePrefix = config.modulePrefix;
- podModulePrefix = config.podModulePrefix;
- Resolver = Resolver;
-}
-
-loadInitializers(App, config.modulePrefix);
diff --git a/app/choices.ts b/app/choices.ts
new file mode 100644
index 00000000..0d559e59
--- /dev/null
+++ b/app/choices.ts
@@ -0,0 +1,182 @@
+import {
+ faBicycle,
+ faCar,
+ faSubway,
+ faWalking,
+} from "@fortawesome/free-solid-svg-icons";
+
+export const languages = [
+ {
+ value: "en-US",
+ label: "English",
+ },
+ {
+ value: "fr-FR",
+ label: "French ",
+ },
+ {
+ value: "de-DE",
+ label: "German ",
+ },
+ {
+ value: "pl-PL",
+ label: "Polish ",
+ },
+ {
+ value: "nl-NI",
+ label: "Dutch ",
+ },
+ {
+ value: "fi-FI",
+ label: "Finnish",
+ },
+ {
+ value: "sv-SE",
+ label: "Swedish",
+ },
+ {
+ value: "it-IT",
+ label: "Italian",
+ },
+ {
+ value: "es-ES",
+ label: "Spanish (Spain)",
+ },
+ {
+ value: "pt-PT",
+ label: "Portuguese (Portugal)",
+ },
+ {
+ value: "ru-RU",
+ label: "Russian",
+ },
+ {
+ value: "pt-BR",
+ label: "Portuguese (Brazil)",
+ },
+ {
+ value: "es-MX",
+ label: "Spanish (Mexico)",
+ },
+ {
+ value: "zh-CN",
+ label: "Chinese (PRC) ",
+ },
+ {
+ value: "zh-TW",
+ label: "Chinese (Taiwan)",
+ },
+ {
+ value: "ja-JP",
+ label: "Japanese",
+ },
+ {
+ value: "ko-KR",
+ label: "Korean ",
+ },
+];
+
+export const themes = [
+ {
+ value: 1,
+ label: "default",
+ },
+ {
+ value: 2,
+ label: "blue",
+ },
+ {
+ value: 3,
+ label: "atl",
+ },
+ {
+ value: 4,
+ label: "red",
+ },
+ {
+ value: 5,
+ label: "dark-blue",
+ },
+ {
+ value: 6,
+ label: "purple",
+ },
+ {
+ value: 7,
+ label: "green",
+ },
+ {
+ value: 8,
+ label: "orange",
+ },
+ {
+ value: 9,
+ label: "dark-green",
+ },
+ {
+ value: 10,
+ label: "austrian",
+ },
+ {
+ value: 11,
+ label: "emory",
+ },
+ {
+ value: 12,
+ label: "gsu",
+ },
+ {
+ value: 13,
+ label: "gatech",
+ },
+ {
+ value: 14,
+ label: "uga",
+ },
+ {
+ value: 15,
+ label: "ksu",
+ },
+];
+
+export const modes = [
+ {
+ value: 1,
+ label: "BICYCLING",
+ icon: faBicycle,
+ },
+ {
+ value: 2,
+ label: "DRIVING",
+ icon: faCar,
+ },
+ {
+ value: 3,
+ label: "TRANSIT",
+ icon: faSubway,
+ },
+ {
+ value: 4,
+ label: "WALKING",
+ icon: faWalking,
+ },
+];
+
+export const mapTypes = [
+ {
+ value: "hybrid",
+ label: "Hybrid",
+ },
+ {
+ value: "roadmap",
+ label: "Roadmap",
+ },
+ {
+ value: "satellite",
+ label: "Satellite",
+ },
+ {
+ value: "terrain",
+ label: "Terrain",
+ },
+];
diff --git a/app/components/AccessRequestForm.tsx b/app/components/AccessRequestForm.tsx
new file mode 100644
index 00000000..7f40c353
--- /dev/null
+++ b/app/components/AccessRequestForm.tsx
@@ -0,0 +1,112 @@
+import {
+ Checkbox,
+ Disclosure,
+ DisclosureButton,
+ DisclosurePanel,
+ Fieldset,
+ Label,
+} from "@headlessui/react";
+import { Fragment, useEffect, useState } from "react";
+import { request } from "~/utils/requests";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faSquare, faSquareCheck } from "@fortawesome/free-regular-svg-icons";
+import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
+import { AnimatePresence, easeOut, motion } from "framer-motion";
+import type { TTourSet } from "~/types";
+
+interface Props {
+ site: TTourSet | undefined;
+}
+
+const AccessRequestForm = ({ site }: Props) => {
+ const [siteTours, setSiteTours] = useState<
+ { title: string; slug: string; id: number }[] | undefined
+ >(undefined);
+
+ useEffect(() => {
+ const getTours = async () => {
+ if (!site) return;
+
+ const { data, response } = await request({
+ path: `${site.subdir}/v4/admin/tours?all=true`,
+ method: "GET",
+ });
+
+ if (response.ok) setSiteTours(data);
+ };
+ if (site) getTours();
+ }, [site]);
+
+ if (site) {
+ return (
+ <>
+
+
+ This will send an email to the admins of{" "}
+ {site.name} with your name and email.
+
+ >
+ );
+ }
+
+ return <>>;
+};
+
+export default AccessRequestForm;
diff --git a/app/components/Account.tsx b/app/components/Account.tsx
new file mode 100644
index 00000000..c8468d29
--- /dev/null
+++ b/app/components/Account.tsx
@@ -0,0 +1,36 @@
+import { Button } from "@headlessui/react";
+import { useContext } from "react";
+import { NavLink, useNavigate } from "react-router";
+import { AuthContext } from "~/contexts";
+import { signOut } from "~/utils/requests";
+import SignIn from "./SignIn.client";
+
+const Account = () => {
+ const { signedIn, currentUser, setCurrentUser } = useContext(AuthContext);
+ const navigate = useNavigate();
+
+ const handelSignOut = async () => {
+ const { response } = await signOut();
+ if (response.ok) {
+ setCurrentUser(undefined);
+ navigate("/signin");
+ }
+ };
+
+ if (signedIn) {
+ return (
+ <>
+ {currentUser && !currentUser.super && }
+
+ >
+ );
+ }
+ return <>>;
+};
+
+export default Account;
diff --git a/app/components/ClientOnly.tsx b/app/components/ClientOnly.tsx
new file mode 100644
index 00000000..876cf0ed
--- /dev/null
+++ b/app/components/ClientOnly.tsx
@@ -0,0 +1,19 @@
+/*
+This was adapted from the ClientOnly component form remix-utils
+https://github.com/sergiodxa/remix-utils/blob/e5795afaf918fb996870a55bbe888bcbc60a4d01/src/react/client-only.tsx
+*/
+import { useIsHydrated } from "~/useIsHydrated";
+import type { ReactNode } from "react";
+
+interface Props {
+ children: ReactNode;
+ fallback?: ReactNode;
+}
+
+const ClientOnly = ({ children, fallback = null }: Props) => {
+ const isHydrated = useIsHydrated;
+
+ return isHydrated() ? <>{children}> : <>{fallback}>;
+};
+
+export default ClientOnly;
diff --git a/app/components/CreateTourSet.tsx b/app/components/CreateTourSet.tsx
new file mode 100644
index 00000000..3dcb8e42
--- /dev/null
+++ b/app/components/CreateTourSet.tsx
@@ -0,0 +1,125 @@
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ Button,
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ DialogTitle,
+ Input,
+ Label,
+} from "@headlessui/react";
+import { useContext, useRef, useState } from "react";
+import { useRevalidator } from "react-router";
+import { AuthContext, FeedbackContext } from "~/contexts";
+import InputWrapper from "./inputs/InputWrapper";
+import { sendCreate } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+
+const CreateTourSet = () => {
+ const { currentUser } = useContext(AuthContext);
+ const [show, setShow] = useState(false);
+ const [siteName, setSiteName] = useState(undefined);
+ const inputRef = useRef(null);
+ const { setFeedback } = useContext(FeedbackContext);
+ const revalidator = useRevalidator();
+
+ const handleChange = () => {
+ if (!inputRef.current) return;
+ setSiteName(inputRef.current.value);
+ };
+
+ const createSite = async () => {
+ if (!inputRef.current) return;
+ setShow(false);
+ setFeedback({ type: "success", message: `Creating ${siteName}` });
+ const { response, data } = await sendCreate({
+ tenant: "public",
+ body: {
+ model: "tour_set",
+ tour_set: {
+ name: siteName,
+ },
+ },
+ });
+
+ if (response.ok) {
+ setSiteName(undefined);
+ setFeedback(undefined);
+ revalidator.revalidate();
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not create the new site."),
+ });
+ }
+ };
+
+ const cancel = () => {
+ setSiteName(undefined);
+ setShow(false);
+ };
+
+ if (!currentUser || !currentUser.super) return <>>;
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default CreateTourSet;
diff --git a/app/components/Error.tsx b/app/components/Error.tsx
new file mode 100644
index 00000000..cdbcb570
--- /dev/null
+++ b/app/components/Error.tsx
@@ -0,0 +1,42 @@
+import {
+ Description,
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ DialogTitle,
+} from "@headlessui/react";
+import { useContext } from "react";
+import { ErrorContext } from "~/contexts";
+
+const Error = () => {
+ const { error, setError } = useContext(ErrorContext);
+ return (
+
+ );
+};
+
+export default Error;
diff --git a/app/components/Feedback.tsx b/app/components/Feedback.tsx
new file mode 100644
index 00000000..feaf5000
--- /dev/null
+++ b/app/components/Feedback.tsx
@@ -0,0 +1,28 @@
+import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
+import { useContext } from "react";
+import { FeedbackContext } from "~/contexts";
+
+const Feedback = () => {
+ const { feedback, setFeedback } = useContext(FeedbackContext);
+ return (
+
+ );
+};
+
+export default Feedback;
diff --git a/app/components/ManageSiteAccess.tsx b/app/components/ManageSiteAccess.tsx
new file mode 100644
index 00000000..0c831943
--- /dev/null
+++ b/app/components/ManageSiteAccess.tsx
@@ -0,0 +1,308 @@
+import { AnimatePresence, easeOut, motion } from "framer-motion";
+import { faCircleXmark } from "@fortawesome/free-regular-svg-icons";
+import { faChevronDown, faUsers } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ Button,
+ CloseButton,
+ Description,
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ DialogTitle,
+ Disclosure,
+ DisclosureButton,
+ DisclosurePanel,
+ Select,
+} from "@headlessui/react";
+import { Fragment, useContext, useEffect, useRef, useState } from "react";
+import { FeedbackContext, TourSetContext } from "~/contexts";
+import { request, sendDelete } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+import { useRevalidator } from "react-router";
+
+const TourSelect = ({
+ adminId,
+ username,
+}: {
+ adminId: number;
+ username: string;
+}) => {
+ const { tourSet } = useContext(TourSetContext);
+ const [selectedTour, setSelectedTour] = useState(
+ undefined,
+ );
+ const selectRef = useRef(null);
+ const revalidator = useRevalidator();
+ const [reassigned, setReassigned] = useState(false);
+
+ useEffect(() => {
+ const reassign = async () => {
+ const { response } = await sendDelete({
+ tenant: tourSet.subdir,
+ record: adminId,
+ body: {
+ model: "tour_set_admin",
+ },
+ });
+
+ if (response.ok && selectRef.current) {
+ const { response: tourAuthorResponse } = await request({
+ path: `${tourSet.subdir}/v4/admin/tour_authors`,
+ method: "POST",
+ body: {
+ tour_id: selectRef.current.value,
+ username,
+ model: "tour_author",
+ },
+ });
+
+ if (tourAuthorResponse.ok) {
+ revalidator.revalidate();
+ setReassigned(true);
+ }
+ }
+ };
+
+ if (selectedTour && selectedTour === selectRef.current?.value) reassign();
+ }, [adminId, tourSet, revalidator, selectedTour, username]);
+
+ const handleSelect = () => {
+ if (!selectRef.current) return;
+
+ setSelectedTour(selectRef.current.value);
+ };
+
+ return (
+
+ );
+};
+
+const ManageSiteAccess = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { tourSet } = useContext(TourSetContext);
+ const { setFeedback } = useContext(FeedbackContext);
+
+ const handleRevoke = async ({
+ record,
+ model,
+ }: {
+ record: number;
+ model: string;
+ }) => {
+ const { response, data } = await sendDelete({
+ record,
+ tenant: tourSet.subdir,
+ body: {
+ model,
+ },
+ });
+
+ if (response.ok) {
+ document.getElementById(`${model}-${record}`)?.remove();
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not revoke access."),
+ });
+ }
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default ManageSiteAccess;
diff --git a/app/components/MapControls.tsx b/app/components/MapControls.tsx
new file mode 100644
index 00000000..77ae9c34
--- /dev/null
+++ b/app/components/MapControls.tsx
@@ -0,0 +1,267 @@
+import { useContext, useEffect, useState } from "react";
+import ClientOnly from "./ClientOnly";
+import SelectInput from "./inputs/SelectInput";
+import TourMap from "./map/TourMap.client";
+import {
+ FeedbackContext,
+ FormContext,
+ OverlayContext,
+ RelatedContext,
+ TourContext,
+} from "~/contexts";
+import { mapTypes } from "~/choices";
+import TextInput from "./inputs/TextInput";
+import MapOverlay from "./map/MapOverlay.client";
+import { useMap } from "@vis.gl/react-google-maps";
+import FileUpload from "./inputs/FileUpload";
+import { sendDelete, sendUpdate } from "~/utils/requests";
+import { useRevalidator } from "react-router";
+import { Deleting } from "./Saving";
+import DeleteButton from "./buttons/DeleteButton";
+import MapOverlayRectangle from "./map/MapOverlayRectangle";
+import { Switch } from "@headlessui/react";
+import ToolTip from "./inputs/ToolTip";
+import { getErrorMessage } from "~/utils/errors";
+import type { TMapOverlay, TTour } from "~/types";
+
+const MapControls = () => {
+ const { tour, setIsSaving } = useContext(TourContext);
+ const { setFeedback } = useContext(FeedbackContext);
+ const [south, setSouth] = useState(undefined);
+ const [north, setNorth] = useState(undefined);
+ const [east, setEast] = useState(undefined);
+ const [west, setWest] = useState(undefined);
+ const [newOverlay, setNewOverlay] = useState(
+ undefined,
+ );
+ const [overlayDraggable, setOverlayDraggable] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const revalidator = useRevalidator();
+ const map = useMap();
+
+ useEffect(() => {
+ if (!tour.map_overlay) return;
+ setSouth(tour.map_overlay.south);
+ setNorth(tour.map_overlay.north);
+ setEast(tour.map_overlay.east);
+ setWest(tour.map_overlay.west);
+ }, [tour]);
+
+ useEffect(() => {
+ if (!newOverlay) return;
+ let intervalId: ReturnType;
+ if (tour.map_overlay?.id !== newOverlay?.id) {
+ setIsSaving(true);
+ intervalId = setInterval(revalidator.revalidate, 1000);
+ }
+
+ return () => {
+ if (intervalId) clearInterval(intervalId);
+ setIsSaving(false);
+ };
+ }, [tour, newOverlay, revalidator, setIsSaving]);
+
+ useEffect(() => {
+ if (tour.map_overlay) setNewOverlay(undefined);
+ }, [tour]);
+
+ const overlayAdded = (updatedTour: unknown) => {
+ setNewOverlay((updatedTour as TTour).map_overlay);
+ };
+
+ const deleteOverlay = async (id: number) => {
+ setDeleting(true);
+ const { response: blankMapResponse, data: blankMapData } =
+ await sendUpdate({
+ record: tour.id,
+ tenant: tour.tenant,
+ body: {
+ model: "tour",
+ tour: { blank_map: false },
+ },
+ });
+
+ if (!blankMapResponse.ok) {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(blankMapData, "Could not remove the map overlay."),
+ });
+ setDeleting(false);
+ return;
+ }
+
+ const { response, data } = await sendDelete({
+ tenant: tour.tenant,
+ record: id,
+ body: {
+ model: "map_overlay",
+ reindex: { id: tour.id, model: "tour" },
+ },
+ });
+
+ if (response.ok) {
+ revalidator.revalidate();
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not remove the map overlay."),
+ });
+ }
+ setDeleting(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {!tour.map_overlay && (
+
+ setIsSaving(true)}
+ btnText="Upload Map Overlay"
+ />
+
+ )}
+
+ {tour.map_overlay && map && (
+ <>
+
+
+
+
+
+ Drag Overlay to Position{" "}
+
+ You can use the the white dots in the corners and along the
+ sides to resize the overlay. Use this toggle if you want to
+ drag the map into position without moving the underlying
+ map.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {deleting ? (
+
+ ) : (
+
+ Remove Overlay{" "}
+
+ )}
+
+ >
+ )}
+
+
+
+
+ );
+};
+
+export default MapControls;
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx
new file mode 100644
index 00000000..26765b47
--- /dev/null
+++ b/app/components/Navbar.tsx
@@ -0,0 +1,90 @@
+import { NavLink } from "react-router";
+import Account from "./Account";
+import { useContext } from "react";
+import { AuthContext, TourSetContext } from "~/contexts";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faBell, faExternalLink } from "@fortawesome/free-solid-svg-icons";
+
+const Navbar = () => {
+ const { tourSet, accessRequests, setAccessRequestModalOpen } =
+ useContext(TourSetContext);
+ const { currentUser } = useContext(AuthContext);
+ return (
+
+ );
+};
+
+export default Navbar;
diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx
new file mode 100644
index 00000000..82c73e3a
--- /dev/null
+++ b/app/components/Pagination.tsx
@@ -0,0 +1,62 @@
+import {
+ faBackwardStep,
+ faCaretLeft,
+ faCaretRight,
+ faForwardStep,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import type { Dispatch, SetStateAction } from "react";
+import type { PaginationLinks } from "~/utils/linkHeader";
+
+interface Props {
+ links: PaginationLinks;
+ current: number;
+ setCurrent: Dispatch>;
+}
+
+const Pagination = ({ links, current, setCurrent }: Props) => {
+ const { first, prev, next, last } = links;
+
+ return (
+
+ );
+};
+
+export default Pagination;
diff --git a/app/components/PendingApproval.tsx b/app/components/PendingApproval.tsx
new file mode 100644
index 00000000..2df1a8b3
--- /dev/null
+++ b/app/components/PendingApproval.tsx
@@ -0,0 +1,90 @@
+import { faBell } from "@fortawesome/free-regular-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ Button,
+ CloseButton,
+ Description,
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ DialogTitle,
+} from "@headlessui/react";
+import { useContext } from "react";
+import UserPendingAccessRequest from "./UserPendingAccessRequest";
+import { TourSetContext } from "~/contexts";
+
+const PendingApproval = () => {
+ const { accessRequests, accessRequestModalOpen, setAccessRequestModalOpen } =
+ useContext(TourSetContext);
+
+ if (!accessRequests || accessRequests.length == 0) return <>>;
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default PendingApproval;
diff --git a/app/components/Preview.tsx b/app/components/Preview.tsx
new file mode 100644
index 00000000..4cc16b38
--- /dev/null
+++ b/app/components/Preview.tsx
@@ -0,0 +1,64 @@
+import { faDesktop, faMobile } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useContext, useEffect, useRef } from "react";
+import { TourContext } from "~/contexts";
+
+const Preview = () => {
+ const { tour } = useContext(TourContext);
+ // const [previewDesktop, setPreviewDesktop] = useState(false);
+ // const [previewMobile, setPreviewMobile] = useState(false);
+ const desktopWindowRef = useRef(null);
+ const mobileWindowRef = useRef(null);
+
+ useEffect(() => {
+ if (desktopWindowRef.current && !desktopWindowRef.current.closed) {
+ desktopWindowRef.current.location.reload();
+ }
+ if (mobileWindowRef.current && !mobileWindowRef.current.closed) {
+ mobileWindowRef.current.location.reload();
+ }
+ }, [tour]);
+
+ const previewDesktop = () => {
+ if (!desktopWindowRef.current || desktopWindowRef.current.closed) {
+ desktopWindowRef.current = window.open(
+ `https://${tour.tenant}.opentour.site/${tour.slug}`,
+ "desktopWindow",
+ `width=${window.innerWidth}, height=${window.innerHeight}`,
+ );
+ } else {
+ desktopWindowRef.current.focus();
+ }
+ };
+
+ const previewMobile = () => {
+ if (!mobileWindowRef.current || mobileWindowRef.current.closed) {
+ mobileWindowRef.current = window.open(
+ `https://${tour.tenant}.opentour.site/${tour.slug}`,
+ "mobileWindow",
+ "width=410, height=730, resizable=no",
+ );
+ } else {
+ mobileWindowRef.current.focus();
+ }
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default Preview;
diff --git a/app/components/Reuse.tsx b/app/components/Reuse.tsx
new file mode 100644
index 00000000..ee865448
--- /dev/null
+++ b/app/components/Reuse.tsx
@@ -0,0 +1,183 @@
+import { useContext, useEffect, useState } from "react";
+import { TourContext } from "~/contexts";
+import { request } from "~/utils/requests";
+import { Description, Dialog, DialogPanel } from "@headlessui/react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ faCircleXmark,
+ faCopy,
+ faPlus,
+} from "@fortawesome/free-solid-svg-icons";
+import { parseLinkHeader, type PaginationLinks } from "~/utils/linkHeader";
+import Pagination from "./Pagination";
+import type { Dispatch, SetStateAction } from "react";
+import type { TFlatPage, TServerResponse, TStop } from "~/types";
+import ToolTip from "./inputs/ToolTip";
+import DeleteButton from "./buttons/DeleteButton";
+
+interface Props {
+ itemIds: number[];
+ model: "stops" | "flat_pages";
+ isOpen: boolean;
+ setIsOpen: Dispatch>;
+ copy: (item: T | undefined) => Promise;
+ add: (item: TServerResponse) => Promise;
+}
+
+const Reuse = ({
+ itemIds,
+ model,
+ isOpen,
+ setIsOpen,
+ copy,
+ add,
+}: Props) => {
+ const [items, setItems] = useState(undefined);
+ const [page, setPage] = useState(1);
+ const { tour } = useContext(TourContext);
+ const [paginationLinks, setPaginationLinks] =
+ useState(null);
+
+ useEffect(() => {
+ const loadItems = async () => {
+ const { response, data, headers } = await request({
+ path: `${tour.tenant}/v4/admin/${model}?page=${page}&per=20&exclude=${itemIds}`,
+ });
+ if (response.ok) {
+ setItems(data);
+ const links = headers?.get("link");
+ if (links) setPaginationLinks(parseLinkHeader(links));
+ }
+ };
+
+ if (isOpen) loadItems();
+ }, [tour, itemIds, model, isOpen, page]);
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default Reuse;
diff --git a/app/components/ReuseMedia.tsx b/app/components/ReuseMedia.tsx
new file mode 100644
index 00000000..fb27fad6
--- /dev/null
+++ b/app/components/ReuseMedia.tsx
@@ -0,0 +1,159 @@
+import {
+ Description,
+ Dialog,
+ DialogPanel,
+ DialogTitle,
+} from "@headlessui/react";
+import {
+ useContext,
+ useEffect,
+ useState,
+ type Dispatch,
+ type SetStateAction,
+} from "react";
+import {
+ FeedbackContext,
+ RecordContext,
+ RelatedContext,
+ TourContext,
+} from "~/contexts";
+import { request } from "~/utils/requests";
+import { parseLinkHeader } from "~/utils/linkHeader";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faCircleXmark, faPlayCircle } from "@fortawesome/free-solid-svg-icons";
+import Pagination from "./Pagination";
+import { joinImage } from "~/utils/image_upload";
+import { getErrorMessage } from "~/utils/errors";
+import type { TMedium } from "~/types";
+import type { PaginationLinks } from "~/utils/linkHeader";
+
+interface Props {
+ onSuccess: (args: unknown) => void;
+ isOpen: boolean;
+ setIsOpen: Dispatch>;
+ itemIds: number[];
+}
+
+const ReuseMedia = ({ isOpen, setIsOpen, onSuccess, itemIds }: Props) => {
+ const [media, setMedia] = useState(undefined);
+ const [page, setPage] = useState(1);
+ const [paginationLinks, setPaginationLinks] =
+ useState(null);
+ const { tour } = useContext(TourContext);
+ const { recordId, recordModel } = useContext(RecordContext);
+ const { relatedModel, relatedType } = useContext(RelatedContext);
+ const { setFeedback } = useContext(FeedbackContext);
+
+ useEffect(() => {
+ const loadMedia = async () => {
+ const { response, data } = await request({
+ path: `${tour.tenant}/v4/admin/media?page=${page}&per=20&exclude=${itemIds}`,
+ });
+ if (response.ok) {
+ setMedia(data);
+ // @ts-expect-error: We know headers will be part of response.
+ setPaginationLinks(parseLinkHeader(response.headers.get("link")));
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not load media to reuse."),
+ });
+ }
+ };
+
+ if (isOpen) loadMedia();
+ }, [isOpen, tour, page, itemIds, setFeedback]);
+
+ const addMedium = async (medium: TMedium) => {
+ setIsOpen(false);
+ setFeedback({ type: "success", message: `Adding ${medium.filename}` });
+ const { response, data } = await joinImage({
+ relatedType,
+ recordModel,
+ relatedModel,
+ recordId,
+ imageId: medium.id,
+ tenant: tour.tenant,
+ });
+ if (response.ok && onSuccess) {
+ onSuccess(data);
+ setFeedback(undefined);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, `Could not add ${medium.filename}.`),
+ });
+ }
+ };
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default ReuseMedia;
diff --git a/app/components/Saving.tsx b/app/components/Saving.tsx
new file mode 100644
index 00000000..be8d46b8
--- /dev/null
+++ b/app/components/Saving.tsx
@@ -0,0 +1,18 @@
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faSpinner } from "@fortawesome/free-solid-svg-icons";
+
+export const Saving = () => {
+ return (
+
+ Saving
+
+ );
+};
+
+export const Deleting = () => {
+ return (
+
+ Deleting
+
+ );
+};
diff --git a/app/components/ScrollableModal.tsx b/app/components/ScrollableModal.tsx
new file mode 100644
index 00000000..f48f70ec
--- /dev/null
+++ b/app/components/ScrollableModal.tsx
@@ -0,0 +1,72 @@
+import { Description, Dialog, DialogPanel } from "@headlessui/react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faCircleXmark } from "@fortawesome/free-solid-svg-icons";
+import {
+ useEffect,
+ useRef,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
+import { useRevalidator } from "react-router";
+
+interface Props {
+ isOpen: boolean;
+ setIsOpen: Dispatch>;
+ children: ReactNode;
+ title: string;
+ description?: string;
+}
+
+const ScrollableModal = ({
+ isOpen,
+ setIsOpen,
+ title,
+ description,
+ children,
+}: Props) => {
+ const openRef = useRef(undefined);
+ const revalidator = useRevalidator();
+
+ useEffect(() => {
+ if (isOpen) openRef.current = true;
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (!isOpen && openRef.current) {
+ revalidator.revalidate();
+ openRef.current = undefined;
+ }
+ }, [isOpen, revalidator]);
+
+ return (
+
+ );
+};
+
+export default ScrollableModal;
diff --git a/app/components/SignIn.client.tsx b/app/components/SignIn.client.tsx
new file mode 100644
index 00000000..04f28221
--- /dev/null
+++ b/app/components/SignIn.client.tsx
@@ -0,0 +1,52 @@
+import { useContext, useRef } from "react";
+import { AuthContext } from "~/contexts";
+import type { ReactNode } from "react";
+import { useNavigate } from "react-router";
+
+const SignIn = ({
+ children,
+ className,
+}: {
+ children?: ReactNode;
+ className?: string;
+}) => {
+ const signInWindowRef = useRef(null);
+ const { setCurrentUser } = useContext(AuthContext);
+ const navigate = useNavigate();
+
+ const redirect = import.meta.env.PROD
+ ? import.meta.env.VITE_SIGN_IN_REDIRECT
+ : "lvh.me:4200";
+
+ const signInCallback = (user: MessageEvent) => {
+ setCurrentUser(user.data);
+ if (user.data.id) navigate("/");
+ window.removeEventListener("message", signInCallback);
+ };
+
+ const handleSignIn = () => {
+ window.addEventListener("message", signInCallback);
+ if (!signInWindowRef.current || signInWindowRef.current.closed) {
+ const width = 410;
+ const height = 730;
+ const left = screen.width / 2 - width / 2;
+ const top = screen.height / 2 - height / 2;
+ signInWindowRef.current = window.open(
+ `https://auth.ecds.io/login?origin=https://${redirect}`,
+ "signInWindow",
+ `width=${width}, height=${height}, left=${left},top=${top}`,
+ );
+ } else {
+ signInWindowRef.current.focus();
+ }
+ };
+ return (
+
+
+
+ );
+};
+
+export default SignIn;
diff --git a/app/components/SiteLogo.tsx b/app/components/SiteLogo.tsx
new file mode 100644
index 00000000..0f2db5fe
--- /dev/null
+++ b/app/components/SiteLogo.tsx
@@ -0,0 +1,95 @@
+import FileUpload from "./inputs/FileUpload";
+import DeleteButton from "./buttons/DeleteButton";
+import { FeedbackContext, FormContext, RecordContext } from "~/contexts";
+import { useContext, useState } from "react";
+import { sendUpdate } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+import ToolTip from "./inputs/ToolTip";
+import type { TTourSet } from "~/types";
+
+interface Props {
+ tourSet: TTourSet;
+}
+
+const SiteLogo = ({ tourSet }: Props) => {
+ const [currentValue, setCurrentValue] = useState(
+ tourSet.logo_url,
+ );
+ const { setFeedback } = useContext(FeedbackContext);
+
+ const onSuccess = (responseData: unknown) => {
+ setCurrentValue((responseData as TTourSet).logo_url);
+ };
+
+ const handleDelete = async (id: number) => {
+ setFeedback({ message: "Removing Logo...", type: "success" });
+ const { response, data } = await sendUpdate({
+ tenant: "public",
+ record: id,
+ body: {
+ model: "tour_set",
+ tour_set: { logo: null },
+ },
+ });
+
+ if (response.ok) {
+ setCurrentValue(undefined);
+ setFeedback(undefined);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not remove the site logo."),
+ });
+ }
+ };
+
+ if (!tourSet) return <>>;
+
+ if (currentValue) {
+ return (
+
+
+
+

+
+
+ Remove Site Logo
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {" "}
+
+ Upload an image to replace the OpenTour logo on your tours. Note: due
+ to size and design considerations your image will be automatically
+ proportionally resized to have a max width of 300 pixels and a max
+ height of 80 pixels.
+
+
+
+ );
+};
+
+export default SiteLogo;
diff --git a/app/components/Terms.tsx b/app/components/Terms.tsx
new file mode 100644
index 00000000..eb3f07ee
--- /dev/null
+++ b/app/components/Terms.tsx
@@ -0,0 +1,340 @@
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ DialogTitle,
+} from "@headlessui/react";
+import { useContext } from "react";
+import { AuthContext, FeedbackContext } from "~/contexts";
+import { sendUpdate } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+
+const Terms = () => {
+ const { currentUser, setCurrentUser } = useContext(AuthContext);
+ const { setFeedback } = useContext(FeedbackContext);
+
+ const agree = async () => {
+ if (!currentUser) return;
+ const { data, response } = await sendUpdate({
+ tenant: "public",
+ record: currentUser.id,
+ body: {
+ user: { terms_accepted: true },
+ model: "user",
+ },
+ });
+
+ if (response.ok) {
+ setCurrentUser(data);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not save your acceptance. Please try again."),
+ });
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default Terms;
diff --git a/app/components/ThemeSelector.tsx b/app/components/ThemeSelector.tsx
new file mode 100644
index 00000000..9e35a57a
--- /dev/null
+++ b/app/components/ThemeSelector.tsx
@@ -0,0 +1,83 @@
+import { faCheckSquare } from "@fortawesome/free-solid-svg-icons";
+import { faSquare } from "@fortawesome/free-regular-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Field, Legend, Radio, RadioGroup } from "@headlessui/react";
+import { useContext, useEffect, useRef, useState } from "react";
+import { themes } from "~/choices";
+import { sendUpdate } from "~/utils/requests";
+import { FeedbackContext, RecordContext, TourContext } from "~/contexts";
+import { getErrorMessage } from "~/utils/errors";
+
+interface Props {
+ theme: number;
+}
+
+const ThemeSelector = ({ theme }: Props) => {
+ const [currentValue, setCurrentValue] = useState(theme);
+ const valueRef = useRef(currentValue);
+ const { tour } = useContext(TourContext);
+ const { recordId } = useContext(RecordContext);
+ const { setFeedback } = useContext(FeedbackContext);
+
+ useEffect(() => {
+ const update = async () => {
+ const { response, data } = await sendUpdate({
+ tenant: tour.tenant,
+ record: recordId,
+ body: {
+ model: "tour",
+ attribute: "theme",
+ value: currentValue,
+ related_model: "theme",
+ related_type: "belongs_to",
+ },
+ });
+
+ if (response.ok) {
+ valueRef.current = currentValue;
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not save the theme."),
+ });
+ setCurrentValue(valueRef.current);
+ }
+ };
+
+ if (currentValue !== valueRef.current) update();
+ }, [tour, recordId, currentValue, setFeedback]);
+
+ return (
+
+
+ {themes.map((theme) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default ThemeSelector;
diff --git a/app/components/TourAuthors.tsx b/app/components/TourAuthors.tsx
new file mode 100644
index 00000000..4fe31be9
--- /dev/null
+++ b/app/components/TourAuthors.tsx
@@ -0,0 +1,86 @@
+import { Button, Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
+import { useContext, useEffect, useState } from "react";
+import { faUsers } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { AuthContext, TourContext, TourSetContext } from "~/contexts";
+import { request } from "~/utils/requests";
+import type { TUser } from "~/types";
+
+const TourAuthors = () => {
+ const { currentUser, currentTenantAdmin } = useContext(AuthContext);
+ const { tourSet } = useContext(TourSetContext);
+ const { tour } = useContext(TourContext);
+ const [isOpen, setIsOpen] = useState(false);
+ const [tourAuthors, setTourAuthors] = useState(
+ undefined,
+ );
+
+ useEffect(() => {
+ if (!tourSet) return;
+
+ const fetchAuthors = async () => {
+ const { data, response } = await request({
+ path: `${tourSet.subdir}/v4/admin/tour_authors?tour_id=${tour.id}`,
+ method: "GET",
+ });
+
+ if (response.ok) {
+ setTourAuthors(data);
+ }
+ };
+
+ if (currentUser?.super || currentTenantAdmin) fetchAuthors();
+ }, [tourSet, tour, currentTenantAdmin, currentUser]);
+
+ const close = () => setIsOpen(false);
+
+ if ((currentUser?.super || currentTenantAdmin) && tourAuthors) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return <>>;
+};
+
+export default TourAuthors;
diff --git a/app/components/TourList.tsx b/app/components/TourList.tsx
new file mode 100644
index 00000000..de4cfb57
--- /dev/null
+++ b/app/components/TourList.tsx
@@ -0,0 +1,82 @@
+import { faCheck, faX } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Link, useParams } from "react-router";
+import ToolTip from "./inputs/ToolTip";
+import { useContext } from "react";
+import { FormContext } from "~/contexts";
+import DeleteButton from "./buttons/DeleteButton";
+import type { ReactNode } from "react";
+import type { TTour } from "~/types";
+
+interface Props {
+ tours: TTour[];
+ heading: string;
+ tenant?: string;
+ children?: ReactNode;
+}
+
+const TourList = ({ tours, heading, tenant, children }: Props) => {
+ const params = useParams();
+ const { handleDelete } = useContext(FormContext);
+
+ return (
+
+ {children}
+
+
+
+ | {heading} |
+
+ Published{" "}
+
+ Until published, the tour will only be visible when signed in to
+ OpenTour.
+
+ |
+ {handleDelete && Delete | }
+
+
+
+ {tours.map((tour, index) => (
+
+ |
+
+ {tour.title}
+
+ |
+
+
+ {tour.published_on ? (
+ <>{tour.published_on}>
+ ) : (
+
+ )}
+
+ |
+ {handleDelete && (
+
+
+ <>>
+
+ |
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default TourList;
diff --git a/app/components/TourSetDescription.tsx b/app/components/TourSetDescription.tsx
new file mode 100644
index 00000000..6821c459
--- /dev/null
+++ b/app/components/TourSetDescription.tsx
@@ -0,0 +1,50 @@
+import { useContext } from "react";
+import { FeedbackContext } from "~/contexts";
+import type { TTourSet } from "~/types";
+import { getErrorMessage } from "~/utils/errors";
+import { sendUpdate } from "~/utils/requests";
+import TextInput from "./inputs/TextInput";
+
+interface Props {
+ tourSet: TTourSet;
+}
+
+const TourSetDescription = ({ tourSet }: Props) => {
+ const { setFeedback } = useContext(FeedbackContext);
+
+ const handleDescriptionUpdate = async (content: string | number) => {
+ setFeedback({ type: "success", message: "Updating Description" });
+ const { response, data } = await sendUpdate({
+ tenant: "public",
+ record: tourSet.id,
+ body: {
+ model: "tour_set",
+ tour_set: { description: content.toString() },
+ },
+ });
+
+ if (response.ok) {
+ setFeedback(undefined);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not create the new site."),
+ });
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default TourSetDescription;
diff --git a/app/components/TourSetList.tsx b/app/components/TourSetList.tsx
new file mode 100644
index 00000000..da4d211b
--- /dev/null
+++ b/app/components/TourSetList.tsx
@@ -0,0 +1,54 @@
+import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Link } from "react-router";
+import type { ReactNode } from "react";
+import type { TTourSet } from "~/types";
+
+interface Props {
+ items: TTourSet[];
+ heading: string;
+ handleDelete?: (id: number) => void;
+ children?: ReactNode;
+}
+
+const TourSetList = ({ items, heading, handleDelete, children }: Props) => {
+ return (
+
+ {children}
+
+
+
+ | {heading} |
+ {handleDelete && Delete | }
+
+
+
+ {items.map((tourSet, index) => (
+
+ |
+
+ {tourSet.name}
+
+ |
+ {handleDelete && (
+
+
+ |
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default TourSetList;
diff --git a/app/components/TravelModes.tsx b/app/components/TravelModes.tsx
new file mode 100644
index 00000000..990ad933
--- /dev/null
+++ b/app/components/TravelModes.tsx
@@ -0,0 +1,201 @@
+import { Checkbox, Fieldset, Legend } from "@headlessui/react";
+import { useContext, useState } from "react";
+import { FeedbackContext, RelatedContext, TourContext } from "~/contexts";
+import { getErrorMessage } from "~/utils/errors";
+import {
+ faBicycle,
+ faCar,
+ faCircleCheck,
+ faSquareCheck,
+ faSubway,
+ faWalking,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import type { TTravelMode, TTravelModeTitle } from "~/types";
+import { sendCreate, sendDelete, sendUpdate } from "~/utils/requests";
+import { faCircle, faSquare } from "@fortawesome/free-regular-svg-icons";
+import { Saving } from "./Saving";
+
+const ICON = (mode: TTravelModeTitle) => {
+ switch (mode) {
+ case "BICYCLING":
+ return faBicycle;
+ case "DRIVING":
+ return faCar;
+ case "TRANSIT":
+ return faSubway;
+ case "WALKING":
+ return faWalking;
+ }
+};
+
+const TravelModes = () => {
+ const { tour, modes, setIsSaving } = useContext(TourContext);
+ const { setFeedback } = useContext(FeedbackContext);
+ const [tourModes, setTourModes] = useState(() => {
+ if (tour.modes.length == 0) {
+ return modes.map((mode) => mode.id);
+ }
+ return tour.modes.map((mode) => mode.id);
+ });
+ const [currentDefaultMode, setCurrentDefaultMode] = useState(
+ tour.mode ?? modes[0],
+ );
+ const [saving, setSaving] = useState(undefined);
+
+ const handleToggle = async (mode: TTravelMode) => {
+ setSaving(mode.id);
+ setIsSaving(true);
+ if (tourModes.includes(mode.id)) {
+ const relationId = tour.modes.find((m) => m.id === mode.id)?.relation_id;
+ if (!relationId) return;
+ const { response, data } = await sendDelete({
+ tenant: tour.tenant,
+ record: relationId,
+ body: { model: "tour_mode" },
+ });
+ if (response.ok) {
+ setTourModes(tourModes.filter((m) => m !== mode.id));
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not disable this travel mode."),
+ });
+ }
+ } else {
+ const { response, data } = await sendCreate({
+ tenant: tour.tenant,
+ body: {
+ model: "tour_mode",
+ tour_mode: { tour_id: tour.id, mode_id: mode.id },
+ },
+ });
+ if (response.ok) {
+ setTourModes([...tourModes, mode.id]);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not enable this travel mode."),
+ });
+ }
+ }
+ setSaving(undefined);
+ setIsSaving(false);
+ };
+
+ const handleDefault = async (mode: TTravelMode) => {
+ setSaving(mode.id);
+ setIsSaving(true);
+ const { response, data } = await sendUpdate({
+ tenant: tour.tenant,
+ record: tour.id,
+ body: {
+ model: "tour",
+ tour: { mode_id: mode.id },
+ },
+ });
+ if (response.ok) {
+ setCurrentDefaultMode(mode);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not set the default travel mode."),
+ });
+ }
+ setSaving(undefined);
+ setIsSaving(false);
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default TravelModes;
diff --git a/app/components/UserPendingAccessRequest.tsx b/app/components/UserPendingAccessRequest.tsx
new file mode 100644
index 00000000..6aa56ce1
--- /dev/null
+++ b/app/components/UserPendingAccessRequest.tsx
@@ -0,0 +1,111 @@
+import {
+ faCircleCheck,
+ faCircleXmark,
+} from "@fortawesome/free-regular-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Button } from "@headlessui/react";
+import { useCallback, useContext, useState } from "react";
+import { useRevalidator } from "react-router";
+import { FeedbackContext, TourSetContext } from "~/contexts";
+import { sendUpdate } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+import type { TAccessRequest } from "~/types";
+
+interface Props {
+ request: TAccessRequest;
+ index: number;
+}
+
+const UserPendingAccessRequest = ({ request, index }: Props) => {
+ const { tourSet } = useContext(TourSetContext);
+ const [selectedTour, setSelectedTour] = useState("");
+ const { setFeedback } = useContext(FeedbackContext);
+ const revalidator = useRevalidator();
+
+ const updateApproval = useCallback(
+ async (approved: boolean, record: number) => {
+ const { data, response } = await sendUpdate({
+ path: `${tourSet.subdir}/v4/admin/access_requests/${record}`,
+ tenant: tourSet.subdir,
+ record,
+ body: {
+ model: "access_request",
+ access_request: {
+ approved,
+ tour_ids: request.tour_ids,
+ },
+ },
+ });
+
+ if (response.ok) {
+ setTimeout(() => {
+ revalidator.revalidate();
+ }, 1000);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(
+ data,
+ "Could not update this access request.",
+ ),
+ });
+ }
+ },
+ [setFeedback, revalidator, tourSet, selectedTour],
+ );
+
+ return (
+
+ | {request.user} |
+ {request.email} |
+
+ {request.tours && request.tours.length > 0 ? (
+ <>{request.tours.join(", ")}>
+ ) : (
+
+ )}
+ |
+
+
+ |
+
+
+ |
+
+ );
+};
+
+export default UserPendingAccessRequest;
diff --git a/app/components/UserTourSet.tsx b/app/components/UserTourSet.tsx
new file mode 100644
index 00000000..01b09b08
--- /dev/null
+++ b/app/components/UserTourSet.tsx
@@ -0,0 +1,80 @@
+import { faSquare, faSquareCheck } from "@fortawesome/free-regular-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Checkbox } from "@headlessui/react";
+import { Fragment, useContext, useState } from "react";
+import { FeedbackContext } from "~/contexts";
+import type { TTourSet, TUser } from "~/types";
+import { sendCreate, sendDelete } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+
+interface Props {
+ tourSet: TTourSet;
+ user: TUser;
+}
+
+const UserTourSet = ({ tourSet, user }: Props) => {
+ const [value, setValue] = useState(
+ user.tour_sets.map((ts) => ts.subdir).includes(tourSet.subdir),
+ );
+ const { setFeedback } = useContext(FeedbackContext);
+
+ const handleChange = async () => {
+ if (value) {
+ // value is still true, so we delete the record.
+ const tourSetAdmin = user.tour_sets.find(
+ (ts) => ts.subdir === tourSet.subdir,
+ );
+ if (!tourSetAdmin) return; // Abort if not actually found
+ const { response, data } = await sendDelete({
+ tenant: "public",
+ record: tourSetAdmin.id,
+ body: {
+ model: "tour_set_admin",
+ },
+ });
+
+ if (response.ok) {
+ setValue(!value);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not remove access."),
+ });
+ }
+ } else {
+ const { response, data } = await sendCreate({
+ tenant: "public",
+ body: {
+ model: "tour_set_admin",
+ tour_set_admin: {
+ user_id: user.id,
+ tour_set_id: tourSet.id,
+ },
+ },
+ });
+
+ if (response.ok) {
+ setValue(!value);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not grant access."),
+ });
+ }
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default UserTourSet;
diff --git a/app/components/buttons/DeleteButton.tsx b/app/components/buttons/DeleteButton.tsx
new file mode 100644
index 00000000..57cd0860
--- /dev/null
+++ b/app/components/buttons/DeleteButton.tsx
@@ -0,0 +1,98 @@
+import {
+ faTrash,
+ type IconDefinition,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ Button,
+ Description,
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+} from "@headlessui/react";
+import { useContext, useState, type ReactNode } from "react";
+import { FormContext } from "~/contexts";
+
+const DeleteButton = ({
+ children,
+ removing,
+ className,
+ disabled = false,
+ label,
+ icon = faTrash,
+ description,
+ message,
+ iconClassName,
+ id,
+}: {
+ children?: ReactNode;
+ removing: string;
+ className?: string;
+ disabled?: boolean;
+ label?: string;
+ icon?: IconDefinition;
+ description?: string;
+ message?: string;
+ iconClassName?: string;
+ id?: number;
+}) => {
+ const { recordId, handleDelete } = useContext(FormContext);
+ const [askConfirm, setAskConfirm] = useState(false);
+
+ const deleteRecord = async () => {
+ if (handleDelete && recordId) handleDelete(recordId);
+ if (handleDelete && id) handleDelete(id);
+ setAskConfirm(false);
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default DeleteButton;
diff --git a/app/components/buttons/EditButton.tsx b/app/components/buttons/EditButton.tsx
new file mode 100644
index 00000000..4203785a
--- /dev/null
+++ b/app/components/buttons/EditButton.tsx
@@ -0,0 +1,20 @@
+import { faPencil } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import type { Dispatch, SetStateAction } from "react";
+
+interface Props {
+ onClick: Dispatch>;
+}
+
+const EditButton = ({ onClick }: Props) => {
+ return (
+
+ );
+};
+
+export default EditButton;
diff --git a/app/components/buttons/SaveButton.tsx b/app/components/buttons/SaveButton.tsx
new file mode 100644
index 00000000..21a747f9
--- /dev/null
+++ b/app/components/buttons/SaveButton.tsx
@@ -0,0 +1,40 @@
+import { faSpinner } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useContext } from "react";
+import { useRevalidator } from "react-router";
+import { TourContext } from "~/contexts";
+
+const SaveButton = () => {
+ const { lastUpdated, isSaving, setIsSaving } = useContext(TourContext);
+ const revalidator = useRevalidator();
+
+ const handleClick = () => {
+ setIsSaving(true);
+ revalidator.revalidate();
+ };
+
+ return (
+ <>
+
+
+ {isSaving ? (
+ <>
+ Saving
+
+ >
+ ) : (
+ <>
+ Last Saved: {lastUpdated}
+ >
+ )}
+
+ >
+ );
+};
+
+export default SaveButton;
diff --git a/app/components/flat_pages/FlatPage.tsx b/app/components/flat_pages/FlatPage.tsx
new file mode 100644
index 00000000..a3dfb987
--- /dev/null
+++ b/app/components/flat_pages/FlatPage.tsx
@@ -0,0 +1,84 @@
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { faBars } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { RecordContext } from "~/contexts";
+import TextInput from "../inputs/TextInput";
+import { useRef } from "react";
+import type { TFlatPage } from "~/types";
+import DeleteButton from "../buttons/DeleteButton";
+
+const FlatPage = ({ flatPage }: { flatPage: TFlatPage }) => {
+ const { attributes, listeners, setNodeRef, transform, transition } =
+ useSortable({ id: flatPage.id });
+
+ const detailsRef = useRef(null);
+
+ const scrollToFlatPage = () => {
+ if (!detailsRef.current) return;
+ if (detailsRef.current.open) detailsRef.current.scrollIntoView();
+ };
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+
+
+
+
+ {flatPage.position}: {flatPage.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default FlatPage;
diff --git a/app/components/flat_pages/FlatPageList.tsx b/app/components/flat_pages/FlatPageList.tsx
new file mode 100644
index 00000000..d39104d5
--- /dev/null
+++ b/app/components/flat_pages/FlatPageList.tsx
@@ -0,0 +1,271 @@
+import { useContext, useEffect, useState } from "react";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ type DragStartEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import {
+ FeedbackContext,
+ FormContext,
+ RelatedContext,
+ TourContext,
+} from "~/contexts";
+import { sendCreate, sendDelete, sendUpdate } from "~/utils/requests";
+import FlatPage from "./FlatPage";
+import ToolTip from "../inputs/ToolTip";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import type { TFlatPage, TServerResponse } from "~/types";
+import Reuse from "../Reuse";
+import { getErrorMessage } from "~/utils/errors";
+
+const FlatPageList = () => {
+ const { relatedModel } = useContext(RelatedContext);
+ const { tour } = useContext(TourContext);
+ const { setFeedback } = useContext(FeedbackContext);
+ const [items, setItems] = useState([]);
+ const [openReuseFlatPages, setOpenReuseFlatPages] = useState(false);
+ const [pendingOpenSlug, setPendingOpenSlug] = useState(
+ undefined,
+ );
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ useEffect(() => {
+ setItems(tour.flat_pages);
+ }, [tour]);
+
+ // Runs after React has committed the newly added page to the DOM, so the
+ // element is guaranteed to exist by the time this effect fires.
+ useEffect(() => {
+ if (!pendingOpenSlug) return;
+ const element = document.getElementById(pendingOpenSlug);
+ if (element) (element as HTMLDetailsElement).open = true;
+ setPendingOpenSlug(undefined);
+ setFeedback(undefined);
+ }, [pendingOpenSlug, items, setFeedback]);
+
+ useEffect(() => {
+ const sendRequest = async (newPosition: number, item: TFlatPage) => {
+ const { response, data } = await sendUpdate({
+ tenant: tour.tenant,
+ record: item.relation_id,
+ body: {
+ attribute: "position",
+ model: relatedModel,
+ value: newPosition,
+ reindex: {
+ model: "tour",
+ id: tour.id,
+ },
+ },
+ });
+
+ if (!response.ok) {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not save the new page order."),
+ });
+ }
+ };
+
+ items.forEach((item, index) => {
+ const newPosition = index + 1;
+ if (newPosition !== item.position) {
+ item.position = newPosition;
+ sendRequest(newPosition, item);
+ }
+ });
+ }, [tour, relatedModel, items, setFeedback]);
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ event.activatorEvent.preventDefault();
+ if (active.id !== over?.id) {
+ setItems((items) => {
+ const oldIndex = items.indexOf(
+ // @ts-expect-error: We know it will be there.
+ items.find((flatPage) => flatPage.id == active.id),
+ );
+ const newIndex = items.indexOf(
+ // @ts-expect-error: We know it will be there.
+ items.find((flatPage) => flatPage.id == over.id),
+ );
+
+ return arrayMove(items, oldIndex, newIndex);
+ });
+ }
+
+ document
+ .getElementsByName("flat_pages")
+ .forEach((flatPage) => ((flatPage as HTMLDetailsElement).open = false));
+ };
+
+ const handleDragStart = (event: DragStartEvent) => {
+ document
+ .getElementsByName("flat_pages")
+ .forEach((flatPage) => ((flatPage as HTMLDetailsElement).open = false));
+ event.activatorEvent.preventDefault();
+ const panel = (event.activatorEvent.target as HTMLElement)?.closest(
+ "details",
+ );
+ if (panel) {
+ panel.open = false;
+ }
+ };
+
+ const createJoin = async (data: TServerResponse) => {
+ setFeedback({ type: "success", message: "Adding Page to Tour." });
+
+ const { response: joinResponse, data: joinData } = await sendCreate({
+ tenant: tour.tenant,
+ body: {
+ model: "tour_flat_page",
+ tour_flat_page: {
+ tour_id: tour.id,
+ flat_page_id: data.id,
+ position: items.length + 1,
+ },
+ reindex: {
+ model: "tour",
+ id: tour.id,
+ },
+ },
+ });
+
+ if (joinResponse.ok) {
+ setItems((items) => [...items, joinData as TFlatPage]);
+ setPendingOpenSlug((data as TFlatPage).slug);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(joinData, "Could not add page to tour."),
+ });
+ }
+ };
+
+ const createFlatPage = async (
+ flatPageToCopy: TFlatPage | undefined = undefined,
+ ) => {
+ setFeedback({ type: "success", message: "Creating Page." });
+
+ const { response, data } = await sendCreate({
+ tenant: tour.tenant,
+ body: {
+ model: "flat_page",
+ flat_page: flatPageToCopy ?? {
+ title: `New Page ${Date.now()}`,
+ },
+ },
+ });
+
+ if (response.ok) {
+ createJoin(data);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not create page."),
+ });
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ setFeedback({ message: "Removing Page...", type: "success" });
+ const { response, data, status } = await sendDelete({
+ tenant: tour.tenant,
+ record: id,
+ body: {
+ model: "tour_flat_page",
+ reindex: {
+ id: tour.id,
+ model: "tour",
+ },
+ },
+ });
+
+ if (response.ok || status === 404) {
+ setItems((items) => items.filter((item) => item.relation_id === id));
+ setFeedback(undefined);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not remove page."),
+ });
+ }
+ };
+
+ return (
+ <>
+
+
+
+
Pages
+
+
+ {" "}
+
+ Create a new page. You can add an About page to describe the
+ project, copyright information, or other data or images using
+ html and the wysiwyg editor. The pages will show up in the
+ menu.
+
+
+
+ {" "}
+ Reuse pages from other tours.
+
+
+ {items.map((flatPage) => (
+
+
+
+ ))}
+
+
+
+ fp.id)}
+ isOpen={openReuseFlatPages}
+ setIsOpen={setOpenReuseFlatPages}
+ copy={createFlatPage}
+ add={createJoin}
+ />
+ >
+ );
+};
+
+export default FlatPageList;
diff --git a/app/components/g-map.hbs b/app/components/g-map.hbs
deleted file mode 100644
index 9f3da4b9..00000000
--- a/app/components/g-map.hbs
+++ /dev/null
@@ -1,20 +0,0 @@
-{{#let (component 'g-map/canvas' onCanvasReady=(fn this.getCanvas)) as |Canvas|}}
- {{#unless (has-block-params)}}
- {{!-- Compatibility: @classNames should be deprecated --}}
-
- {{/unless}}
-
- {{yield (hash
- map=this.map
- canvas=Canvas
- imageOverlay=(component 'g-map/image-overlay' getContext=(fn this.getComponent))
- marker=(component 'g-map/marker' getContext=(fn this.getComponent))
- circle=(component 'g-map/circle' getContext=(fn this.getComponent))
- polyline=(component 'g-map/polyline' getContext=(fn this.getComponent))
- directions=(component 'g-map/directions' getContext=(fn this.getComponent))
- control=(component 'g-map/control' getContext=(fn this.getComponent))
- overlay=(component 'g-map/overlay' getContext=(fn this.getComponent))
- infoWindow=(component 'g-map/info-window' getContext=(fn this.getComponent))
- autocomplete=(component 'g-map/autocomplete' getContext=(fn this.getComponent))
- )}}
-{{/let}}
\ No newline at end of file
diff --git a/app/components/g-map.js b/app/components/g-map.js
deleted file mode 100644
index f8400a19..00000000
--- a/app/components/g-map.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import GMap from 'ember-google-maps/components/g-map';
-
-export default class GMapComponent extends GMap {
-}
diff --git a/app/components/g-map/image-overlay.hbs b/app/components/g-map/image-overlay.hbs
deleted file mode 100644
index b30f308e..00000000
--- a/app/components/g-map/image-overlay.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-{{#if (has-block)}}
- {{#if this.container}}
- {{#in-element this.container}}
-
-

- {{yield}}
-
- {{/in-element}}
- {{/if}}
-{{/if}}
\ No newline at end of file
diff --git a/app/components/g-map/image-overlay.js b/app/components/g-map/image-overlay.js
deleted file mode 100644
index bc149d86..00000000
--- a/app/components/g-map/image-overlay.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import OverlayView from 'ember-google-maps/components/g-map/overlay';
-import { action } from '@ember/object';
-/* global google */
-
-export default class GMapImageOverlayComponent extends OverlayView {
-
- overlayElement = null;
- paneName = 'overlayLayer';
-
- get position() {
- let { south, west, north, east } = this.args;
-
- if ([south, west, north, east].some( boundary => boundary == null)) {
- return null;
- }
- return this.toBounds(south, west, north, east);
- }
-
- toBounds(south, west, north, east) {
- return new google.maps.LatLngBounds(
- new google.maps.LatLng(south, west),
- new google.maps.LatLng(north, east)
- );
- }
-
- onAdd() {
- // this.map.fitBounds(this.position);
-
- let panes = this.mapComponent.getPanes();
- this.targetPane = panes[this.paneName];
-
- this.targetPane.appendChild(this.overlayElement);
-
- this.addEventsToMapComponent(
- this.overlayElement,
- this.events,
- this.publicAPI
- );
- }
-
- draw() {
- let { position, zIndex } = this;
-
- let overlayProjection = this.mapComponent.getProjection();
- let sw = overlayProjection.fromLatLngToDivPixel(position.getSouthWest());
- let ne = overlayProjection.fromLatLngToDivPixel(position.getNorthEast());
-
-
- this.overlayElement.style.cssText = `
- position: absolute;
- left: ${sw.x}px;
- top: ${ne.y}px;
- height: ${sw.y - ne.y}px;
- width: ${ne.x - sw.x}px;
- z-index: ${zIndex};
- `;
-
- this.mapComponent.didDraw ||= true;
- }
-
- @action
- getOverlay(element) {
- this.overlayElement = element;
- }
-
- @action
- fitBounds() {
- google.maps.event.addListenerOnce(this.map, 'idle', () => {
- if (this.args.onLoad) this.args.onLoad();
- });
-
- if (!this.args.maintainBounds) this.map.fitBounds(this.position);
- }
-}
diff --git a/app/components/inputs/FileUpload.tsx b/app/components/inputs/FileUpload.tsx
new file mode 100644
index 00000000..e2b05b77
--- /dev/null
+++ b/app/components/inputs/FileUpload.tsx
@@ -0,0 +1,142 @@
+import { useContext, useRef } from "react";
+import {
+ FeedbackContext,
+ RecordContext,
+ RelatedContext,
+ TourContext,
+} from "~/contexts";
+import { imageUpload, joinImage } from "~/utils/image_upload";
+import { getErrorMessage } from "~/utils/errors";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faUpload } from "@fortawesome/free-solid-svg-icons";
+import type { Dispatch, ReactNode, SetStateAction } from "react";
+import type { TModel } from "~/types";
+import { safeId } from "~/utils/a11y";
+
+interface Props {
+ onSuccess?: (arg: unknown) => void;
+ fileUploading?: Dispatch>;
+ btnText?: string | ReactNode;
+ children?: ReactNode;
+ model?: TModel;
+ onStart?: () => void;
+ className?: string;
+ updateId?: number;
+ attribute?: string;
+ accept?: string;
+ multiple?: boolean;
+ handleUpload?: (inputElement: HTMLInputElement) => void;
+ disabled?: boolean;
+}
+
+const FileUpload = ({
+ onSuccess,
+ btnText,
+ children,
+ model = "medium",
+ onStart,
+ fileUploading,
+ className,
+ updateId,
+ attribute = "file",
+ accept,
+ multiple = true,
+ handleUpload,
+ disabled = false,
+}: Props) => {
+ const inputRef = useRef(null);
+ const { tour } = useContext(TourContext);
+ const { recordId, recordModel } = useContext(RecordContext);
+ const { relatedModel, relatedType } = useContext(RelatedContext);
+ const { setFeedback } = useContext(FeedbackContext);
+
+ const inputId = safeId();
+
+ const handleFileSelected = async () => {
+ if (onStart) onStart();
+ if (!inputRef.current) return;
+ if (handleUpload) {
+ handleUpload(inputRef.current);
+ // Reset so selecting the same file again (e.g. after canceling)
+ // still fires onChange — browsers only fire it when the value changes.
+ inputRef.current.value = "";
+ return;
+ }
+ if (!inputRef.current.files) return;
+
+ for (const file of inputRef.current.files) {
+ if (fileUploading) fileUploading(file.name);
+ setFeedback({ type: "success", message: "File Uploading." });
+ const { response: uploadResponse, data: uploadData } = await imageUpload({
+ tenant: tour.tenant ?? "public",
+ file,
+ model,
+ recordId: updateId,
+ attribute,
+ });
+
+ if (uploadResponse.ok && updateId) {
+ if (onSuccess) onSuccess(uploadData);
+ setFeedback(undefined);
+ } else if (relatedModel && relatedType && uploadResponse.ok) {
+ const { response, data } = await joinImage({
+ relatedType,
+ recordModel,
+ relatedModel,
+ recordId,
+ imageId: uploadData.id,
+ tenant: tour.tenant ?? "public",
+ });
+ if (response.ok && onSuccess) {
+ onSuccess(data);
+ setFeedback(undefined);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not add this file."),
+ });
+ }
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(uploadData, "Could not upload this file."),
+ });
+ }
+ }
+
+ if (fileUploading) fileUploading(undefined);
+ // Reset so selecting the same file again still fires onChange —
+ // browsers only fire it when the value changes.
+ inputRef.current.value = "";
+ };
+
+ return (
+
+ );
+};
+
+export default FileUpload;
diff --git a/app/components/inputs/InputWrapper.tsx b/app/components/inputs/InputWrapper.tsx
new file mode 100644
index 00000000..ec77f02c
--- /dev/null
+++ b/app/components/inputs/InputWrapper.tsx
@@ -0,0 +1,13 @@
+import { Field } from "@headlessui/react";
+import type { ReactNode } from "react";
+
+interface Props {
+ children: ReactNode;
+ className?: string;
+}
+
+const InputWrapper = ({ children, className }: Props) => {
+ return {children};
+};
+
+export default InputWrapper;
diff --git a/app/components/inputs/SelectInput.tsx b/app/components/inputs/SelectInput.tsx
new file mode 100644
index 00000000..68455737
--- /dev/null
+++ b/app/components/inputs/SelectInput.tsx
@@ -0,0 +1,154 @@
+import { Checkbox, Description, Label, Select } from "@headlessui/react";
+import InputWrapper from "./InputWrapper";
+import ToolTip from "./ToolTip";
+import { useContext, useEffect, useRef, useState } from "react";
+import { sendUpdate } from "~/utils/requests";
+import { ErrorContext, RecordContext, TourContext } from "~/contexts";
+import { useRevalidator } from "react-router";
+import { useSyncPoll } from "~/hooks/useSyncPoll";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ faChevronDown,
+ faSquareCheck,
+} from "@fortawesome/free-solid-svg-icons";
+import { faSquare } from "@fortawesome/free-regular-svg-icons";
+import type { InputProps, TChoices, TSelectableProps } from "~/types";
+import { safeId } from "~/utils/a11y";
+
+type SelectProps = {
+ value: string | boolean;
+ options?: TChoices[];
+ id: TSelectableProps;
+ handleSave?: () => void;
+};
+
+const SelectInput = ({
+ id,
+ label,
+ model,
+ value,
+ helpText,
+ options,
+}: InputProps & SelectProps) => {
+ const [currentValue, setCurrentValue] = useState(value);
+ const inputRef = useRef(null);
+ const valueRef = useRef(value);
+ const { tour, isSaving, setIsSaving } = useContext(TourContext);
+ const { recordId } = useContext(RecordContext);
+ const { error, setError } = useContext(ErrorContext);
+ const revalidator = useRevalidator();
+ const inputId = safeId();
+
+ useEffect(() => {
+ const update = async () => {
+ const { response } = await sendUpdate({
+ tenant: tour.tenant,
+ record: recordId,
+ body: { model, [model]: { [id]: currentValue } },
+ });
+ if (response.ok) {
+ valueRef.current = currentValue;
+ } else {
+ if (setError) setError(`An error occurred updating ${label}.`);
+ }
+ };
+
+ if (valueRef.current !== currentValue) update();
+ }, [currentValue, model, id, recordId, revalidator, tour, setError, label]);
+
+ useEffect(() => {
+ if (error) {
+ setCurrentValue(valueRef.current);
+ setIsSaving(false);
+ }
+ }, [error, tour, id, setIsSaving]);
+
+ const isPendingSync =
+ tour[id as keyof typeof tour] !== currentValue && !error;
+
+ useSyncPoll({
+ pending: isPendingSync,
+ revalidate: revalidator.revalidate,
+ onTimeout: () => {
+ if (setError)
+ setError(`Could not confirm ${label} was saved. Please refresh.`);
+ },
+ });
+
+ useEffect(() => {
+ setIsSaving(isPendingSync);
+ }, [isPendingSync, setIsSaving]);
+
+ const handleSelect = () => {
+ if (!inputRef.current) return;
+ setCurrentValue(inputRef.current.value);
+ };
+
+ if (options) {
+ return (
+
+
+ {helpText && (
+
+ {helpText}
+
+ )}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ <>
+ setCurrentValue(!currentValue)}
+ >
+
+
+
+ {helpText && (
+
+ {helpText}
+
+ )}
+ >
+
+ );
+};
+
+export default SelectInput;
diff --git a/app/components/inputs/TextInput.tsx b/app/components/inputs/TextInput.tsx
new file mode 100644
index 00000000..4f0dfcf3
--- /dev/null
+++ b/app/components/inputs/TextInput.tsx
@@ -0,0 +1,324 @@
+import {
+ lazy,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { Description, Input, Textarea } from "@headlessui/react";
+import { sendUpdate } from "~/utils/requests";
+import InputWrapper from "./InputWrapper";
+import { RecordContext, TourContext } from "~/contexts";
+import ToolTip from "./ToolTip";
+import ClientOnly from "../ClientOnly";
+import { useRevalidator } from "react-router";
+import { enforceA11yOnLinks } from "~/utils/a11y";
+import { useSyncPoll } from "~/hooks/useSyncPoll";
+import { findRecordValue } from "~/utils/find_in_tour";
+import type { InputProps, TServerResponse } from "~/types";
+
+const JoditEditor = lazy(() => import("jodit-react"));
+
+const padding = (size: "large" | "small") => {
+ switch (size) {
+ case "large":
+ return "px-4 py-3.5";
+ case "small":
+ return "px-3 py-2.5";
+ default:
+ break;
+ }
+};
+
+type RangeProps = {
+ max: number;
+ min: number;
+};
+
+type TextInputProps = {
+ value: string | number;
+ type: "text" | "text-area" | "rich-text" | "color" | "range";
+ itemId?: number;
+ updateCallback?: (data: TServerResponse) => void;
+ size?: "small" | "large";
+ valueType?: "text" | "number" | "url" | "button" | "file";
+ placeholder?: string;
+ range?: RangeProps;
+};
+
+const TextInput = ({
+ helpText,
+ id,
+ label,
+ model,
+ value,
+ type,
+ onChange,
+ itemId,
+ updateCallback,
+ size = "large",
+ valueType = "text",
+ placeholder,
+ range,
+}: InputProps & TextInputProps) => {
+ const [currentValue, setCurrentValue] = useState(value);
+ const [serverError, setServerError] = useState(undefined);
+ const inputRef = useRef(null);
+ const valueRef = useRef(value);
+ // Set only once a save has actually gone out, so we only poll while
+ // waiting to see OUR write reflected — not whenever `value` happens to
+ // differ from tour data for unrelated reasons (e.g. a live map viewport).
+ const savedValueRef = useRef(undefined);
+ // Jodit is only sanitized/committed on blur, not on every keystroke — see
+ // handleRichTextChange for why.
+ const richTextRef = useRef(typeof value === "string" ? value : "");
+ const { tour, setLastUpdated, setIsSaving } = useContext(TourContext);
+ const { recordId } = useContext(RecordContext);
+ const revalidator = useRevalidator();
+
+ const config = useMemo(
+ () => ({
+ readonly: false,
+ placeholder: "Start typing...",
+ buttons: [
+ "bold",
+ "strikethrough",
+ "underline",
+ "italic",
+ "ul",
+ "ol",
+ "link",
+ "indent",
+ "undo",
+ "redo",
+ "source",
+ ],
+ statusbar: false,
+ height: 200,
+ toolbarAdaptive: false,
+ showCharsCounter: false,
+ showWordsCounter: false,
+ hidePoweredByJodit: true,
+ showXPathInStatusbar: false,
+ // Enforce a11y whenever Jodit processes a link dialog save
+ // or the user pastes content with links
+ events: {
+ afterInsertNode(node: Node) {
+ if (
+ node instanceof HTMLAnchorElement &&
+ node.getAttribute("target") === "_blank"
+ ) {
+ const rel = new Set(
+ (node.getAttribute("rel") ?? "").split(" ").filter(Boolean),
+ );
+ rel.add("noopener");
+ rel.add("noreferrer");
+ node.setAttribute("rel", [...rel].join(" "));
+
+ const linkText = node.textContent?.trim() ?? "";
+ if (
+ !node.getAttribute("aria-label")?.includes("opens in a new tab")
+ ) {
+ node.setAttribute(
+ "aria-label",
+ `${linkText} (opens in a new tab)`,
+ );
+ }
+ }
+ },
+ },
+ }),
+ [],
+ );
+
+ const update = useCallback(async () => {
+ if (!tour) return;
+ const valueToSave =
+ type === "rich-text" ? richTextRef.current : currentValue;
+ setIsSaving(true);
+ const { response, data } = await sendUpdate({
+ tenant: tour.tenant,
+ record: itemId ?? recordId,
+ body: {
+ model,
+ [model]: { [id]: valueToSave },
+ reindex: { id: tour.id, model: "tour" },
+ },
+ });
+
+ setIsSaving(false);
+ if (response.ok) {
+ setServerError(undefined);
+ valueRef.current = valueToSave;
+ savedValueRef.current = valueToSave;
+ setCurrentValue(valueToSave);
+ if (updateCallback) {
+ updateCallback(data as TServerResponse);
+ const now = new Date();
+ setLastUpdated(now.toLocaleString());
+ }
+ } else {
+ const detail = data?.errors?.[0]?.detail;
+ setServerError(detail ?? "Could not save. Please try again.");
+ setCurrentValue(valueRef.current);
+ if (type === "rich-text")
+ richTextRef.current = valueRef.current as string;
+ }
+ }, [
+ tour,
+ recordId,
+ model,
+ currentValue,
+ id,
+ itemId,
+ type,
+ updateCallback,
+ setLastUpdated,
+ setIsSaving,
+ ]);
+
+ useEffect(() => {
+ setCurrentValue(value);
+ if (type === "rich-text" && typeof value === "string") {
+ richTextRef.current = value;
+ }
+ }, [value, type]);
+
+ const targetRecordId = itemId ?? recordId;
+ const confirmedValue = tour
+ ? findRecordValue(tour, model, targetRecordId, id)
+ : undefined;
+ const isPendingSync =
+ savedValueRef.current !== undefined &&
+ String(confirmedValue) !== String(savedValueRef.current);
+
+ useSyncPoll({
+ pending: isPendingSync,
+ revalidate: revalidator.revalidate,
+ onTimeout: () => {
+ setServerError("Could not confirm the save. Please refresh.");
+ savedValueRef.current = undefined;
+ },
+ });
+
+ useEffect(() => {
+ if (!isPendingSync) savedValueRef.current = undefined;
+ }, [isPendingSync]);
+
+ const handleChange = () => {
+ if (!inputRef.current) return;
+ setCurrentValue(inputRef.current.value);
+ if (onChange)
+ onChange(
+ type === "rich-text" ? richTextRef.current : inputRef.current.value,
+ );
+ };
+
+ // Buffer into a ref instead of setState: feeding the sanitized value back
+ // into Jodit's controlled `value` prop on every keystroke made the cursor
+ // jump to the start and dropped keystrokes. The buffered value is only
+ // committed to state (and sent to the server) on blur, in `update()`.
+ const handleRichTextChange = useCallback((newValue: string) => {
+ richTextRef.current = enforceA11yOnLinks(newValue);
+ }, []);
+
+ const handleBlur = async () => {
+ if (onChange) {
+ const newValue =
+ type === "rich-text" ? richTextRef.current : inputRef.current?.value;
+ onChange(newValue ?? "");
+ } else {
+ await update();
+ }
+ };
+
+ return (
+
+
+ {helpText && (
+
+ {helpText}
+
+ )}
+
+ {type === "text" && (
+
+ )}
+ {type === "text-area" && (
+
+ )}
+ {type === "rich-text" && (
+
+
+
+ )}
+ {/* {valueType === "file" &&
} */}
+ {type === "color" && (
+
+ )}
+ {type === "range" && range && (
+ <>
+
+
+
{range.min}
+
{currentValue}
+
{range.max}
+
+ >
+ )}
+
+
+ {serverError ?? inputRef.current?.validationMessage}
+
+
+ );
+};
+
+export default TextInput;
diff --git a/app/components/inputs/ToolTip.tsx b/app/components/inputs/ToolTip.tsx
new file mode 100644
index 00000000..db109dd7
--- /dev/null
+++ b/app/components/inputs/ToolTip.tsx
@@ -0,0 +1,31 @@
+import { faCircleQuestion } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { safeId } from "~/utils/a11y";
+import type { ReactNode } from "react";
+
+interface Props {
+ children: ReactNode;
+}
+
+const ToolTip = ({ children }: Props) => {
+ const id = safeId();
+ return (
+
+ );
+};
+
+export default ToolTip;
diff --git a/app/components/language-select/component.js b/app/components/language-select/component.js
deleted file mode 100644
index 26b9c65f..00000000
--- a/app/components/language-select/component.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-
-export default class LanguageSelectComponent extends Component {
- languages = [
- {
- code: 'en-US',
- label: 'English'
- },
- {
- code: 'fr-FR',
- label: 'French '
- },
- {
- code: 'de-DE',
- label: 'German '
- },
- {
- code: 'pl-PL',
- label: 'Polish '
- },
- {
- code: 'nl-NI',
- label: 'Dutch '
- },
- {
- code: 'fi-FI',
- label: 'Finnish'
- },
- {
- code: 'sv-SE',
- label: 'Swedish'
- },
- {
- code: 'it-IT',
- label: 'Italian'
- },
- {
- code: 'es-ES',
- label: 'Spanish (Spain)'
- },
- {
- code: 'pt-PT',
- label: 'Portuguese (Portugal)'
- },
- {
- code: 'ru-RU',
- label: 'Russian'
- },
- {
- code: 'pt-BR',
- label: 'Portuguese (Brazil)'
- },
- {
- code: 'es-MX',
- label: 'Spanish (Mexico)'
- },
- {
- code: 'zh-CN',
- label: 'Chinese (PRC) '
- },
- {
- code: 'zh-TW',
- label: 'Chinese (Taiwan)'
- },
- {
- code: 'ja-JP',
- label: 'Japanese'
- },
- {
- code: 'ko-KR',
- label: 'Korean '
- }
- ];
-
- @action
- update(event) {
- this.args.model.setProperties({ defaultLng: event.target.value });
- this.args.save.perform(this.args.model);
- }
-}
diff --git a/app/components/language-select/template.hbs b/app/components/language-select/template.hbs
deleted file mode 100644
index a79b2545..00000000
--- a/app/components/language-select/template.hbs
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/components/map-icons/component.js b/app/components/map-icons/component.js
deleted file mode 100644
index 48194aa7..00000000
--- a/app/components/map-icons/component.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import Component from '@glimmer/component';
-import { task } from 'ember-concurrency';
-import { action } from '@ember/object';
-import UIKit from 'uikit';
-
-export default class MapIconsComponent extends Component {
- @task
- *upload(file) {
- const newIcon = yield this.args.upload.perform(this.args.model, file, 'mapIcon', false);
- if (newIcon) {
- yield this.args.addToModel.perform(this.args.model, newIcon, 'mapIcon');
- }
- }
-
- @action
- addIcon(icon) {
- this.args.addToModel.perform(this.args.model, icon, 'mapIcon');
- }
-
- @action
- initToggle(element) {
- UIKit.toggle(element, { target: `#existing-icons-${this.args.model.get('slug')}${this.args.model.get('id')}` });
- }
-
- @action
- initModal(element) {
- this.modal = UIKit.modal(element);
- }
-}
diff --git a/app/components/map-icons/template.hbs b/app/components/map-icons/template.hbs
deleted file mode 100644
index a2e56ab8..00000000
--- a/app/components/map-icons/template.hbs
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{#if (gt @icons.length 0)}}
-
- {{/if}}
- {{#if @model.mapIcon}}
-
-
-
- Remove Custom Icon
-
-
-
- {{/if}}
-
-
-{{#if (gt @icons.length 0)}}
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/app/components/map/Location.tsx b/app/components/map/Location.tsx
new file mode 100644
index 00000000..79b16b69
--- /dev/null
+++ b/app/components/map/Location.tsx
@@ -0,0 +1,202 @@
+import {
+ useContext,
+ useEffect,
+ useRef,
+ type Dispatch,
+ type SetStateAction,
+} from "react";
+import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faLocationCrosshairs } from "@fortawesome/free-solid-svg-icons";
+import { Fieldset, Input, Label } from "@headlessui/react";
+import InputWrapper from "../inputs/InputWrapper";
+import { FeedbackContext, StopMapContext, TourContext } from "~/contexts";
+import { sendUpdate } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+
+interface Props {
+ lng: number | undefined;
+ lat: number | undefined;
+ address: string | undefined;
+ setLat: Dispatch>;
+ setLng: Dispatch>;
+ setAddress: Dispatch>;
+ prefix?: "parking";
+}
+
+const Location = ({
+ lat,
+ lng,
+ address,
+ setLat,
+ setLng,
+ setAddress,
+ prefix,
+}: Props) => {
+ const addressInputRef = useRef(null);
+ const latInputRef = useRef(null);
+ const lngInputRef = useRef(null);
+ const { setIsSaving, setLastUpdated, tour } = useContext(TourContext);
+ const { setFeedback } = useContext(FeedbackContext);
+ const stopContext = useContext(StopMapContext);
+ if (!stopContext) throw new Error("StopMapContext is undefined");
+ const { stop } = stopContext;
+ const geocoderLib = useMapsLibrary("geocoding");
+ const map = useMap();
+
+ useEffect(() => {
+ if (!map || !lat || !lng) return;
+
+ map.setCenter({ lat, lng });
+ }, [map, lat, lng]);
+
+ useEffect(() => {
+ const update = async () => {
+ setIsSaving(true);
+
+ const { response, data } = await sendUpdate({
+ record: stop.id,
+ tenant: tour.tenant,
+ body: {
+ model: "stop",
+ stop: {
+ [prefix ? `${prefix}_address` : "address"]: address,
+ [prefix ? `${prefix}_lat` : "lng"]: lng,
+ [prefix ? `${prefix}_lng` : "lat"]: lat,
+ },
+ },
+ });
+
+ setIsSaving(false);
+
+ if (response.ok) {
+ const now = new Date();
+ setLastUpdated(now.toLocaleString());
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not save the location."),
+ });
+ }
+ };
+
+ const timeoutId = setTimeout(() => {
+ if (
+ prefix &&
+ (stop[`${prefix}_lat`] !== lat ||
+ stop[`${prefix}_lng`] !== lng ||
+ stop[`${prefix}_address`] !== address)
+ ) {
+ update();
+ } else if (
+ stop.lat !== lat ||
+ stop.lng !== lng ||
+ stop.address !== address
+ ) {
+ update();
+ }
+ }, 500);
+
+ return () => clearTimeout(timeoutId);
+ }, [
+ lat,
+ lng,
+ address,
+ prefix,
+ stop,
+ tour,
+ setIsSaving,
+ setLastUpdated,
+ setFeedback,
+ ]);
+
+ const handleInput = () => {
+ if (
+ !addressInputRef.current ||
+ !latInputRef.current ||
+ !lngInputRef.current
+ )
+ return;
+
+ setAddress(addressInputRef.current.value);
+ setLat(parseFloat(latInputRef.current.value));
+ setLng(parseFloat(lngInputRef.current.value));
+ };
+
+ const locateAddress = () => {
+ if (!geocoderLib || !lat || !lng) return;
+
+ const locater = new geocoderLib.Geocoder();
+ const geoCoderOpts = address ? { address } : { location: { lat, lng } };
+ locater.geocode(geoCoderOpts, (result, status) => {
+ if (status === "OK" && result) {
+ setLat(result[0].geometry.location.lat());
+ setLng(result[0].geometry.location.lng());
+ setAddress(result[0].formatted_address);
+ } else {
+ console.error(status);
+ }
+ });
+ };
+
+ if (!lat || !lng) return <>>;
+
+ return (
+
+ );
+};
+
+export default Location;
diff --git a/app/components/map/MapMarker.tsx b/app/components/map/MapMarker.tsx
new file mode 100644
index 00000000..c27b2205
--- /dev/null
+++ b/app/components/map/MapMarker.tsx
@@ -0,0 +1,61 @@
+import {
+ AdvancedMarker,
+ Pin,
+ useAdvancedMarkerRef,
+ useMap,
+} from "@vis.gl/react-google-maps";
+import { useContext, useEffect } from "react";
+import { StopMapContext } from "~/contexts";
+
+interface Props {
+ draggable?: boolean;
+ onDragEnd?: (event: google.maps.MapMouseEvent) => void;
+ center?: boolean;
+}
+
+const MapMarker = ({ draggable, onDragEnd, center = false }: Props) => {
+ const context = useContext(StopMapContext);
+ if (!context) throw new Error("StopMapContext is undefined");
+ const { iconColor, lat, lng, mapIcon, position } = context;
+ const [markerRef, marker] = useAdvancedMarkerRef();
+ const map = useMap();
+
+ useEffect(() => {
+ return () => {
+ if (marker) marker.map = null;
+ };
+ }, [marker]);
+
+ useEffect(() => {
+ if (!center || !marker || !map || !lat || !lng) return;
+
+ map.setCenter({ lat, lng });
+ map.setZoom(16);
+ }, [center, marker, map, lat, lng]);
+
+ if (!lat || !lng) return <>>;
+
+ return (
+ <>
+
+ {mapIcon ? (
+
+ ) : (
+
+ {position}
+
+ )}
+
+ >
+ );
+};
+
+export default MapMarker;
diff --git a/app/components/map/MapOverlay.client.tsx b/app/components/map/MapOverlay.client.tsx
new file mode 100644
index 00000000..dc9f80bf
--- /dev/null
+++ b/app/components/map/MapOverlay.client.tsx
@@ -0,0 +1,247 @@
+import { useMap } from "@vis.gl/react-google-maps";
+import { useContext, useEffect } from "react";
+import { OverlayContext, TourContext } from "~/contexts";
+
+// Usage
+// const map = new google.maps.Map(document.getElementById("map") as HTMLElement, {
+// center: { lat: 40.7128, lng: -74.006 },
+// zoom: 12,
+// });
+
+// const bounds = new google.maps.LatLngBounds(
+// new google.maps.LatLng(40.7, -74.02),
+// new google.maps.LatLng(40.72, -74.0),
+// );
+
+// const overlay = new DraggableGroundOverlay(
+// bounds,
+// "path/to/your/image.png",
+// map,
+// );
+
+const MapOverlay = ({ editable = true }: { editable?: boolean }) => {
+ const { tour } = useContext(TourContext);
+ const { south, north, east, west } = useContext(OverlayContext);
+ const map = useMap();
+
+ useEffect(() => {
+ if (!map || !tour.map_overlay) return;
+ if (!south || !north || !east || !west) return;
+
+ class DraggableGroundOverlay extends google.maps.OverlayView {
+ private bounds_: google.maps.LatLngBounds;
+ private image_: string;
+ private div_: HTMLDivElement | null = null;
+
+ constructor(bounds: google.maps.LatLngBounds, image: string) {
+ super();
+ this.bounds_ = bounds;
+ this.image_ = image;
+ }
+
+ onAdd(): void {
+ const div = document.createElement("div");
+ div.style.position = "absolute";
+ div.style.cursor = "move";
+
+ const img = document.createElement("img");
+ img.src = this.image_;
+ img.style.width = "100%";
+ img.style.height = "100%";
+ img.style.position = "absolute";
+ div.appendChild(img);
+
+ this.div_ = div;
+
+ // Add drag functionality
+ // this.addDragListeners();
+
+ const panes = this.getPanes();
+ if (panes) {
+ // @ts-expect-error: overlayImage is too a pane!
+ panes.overlayImage.appendChild(div);
+ }
+ }
+
+ draw(): void {
+ const overlayProjection = this.getProjection();
+ const sw = overlayProjection.fromLatLngToDivPixel(
+ this.bounds_.getSouthWest(),
+ );
+ const ne = overlayProjection.fromLatLngToDivPixel(
+ this.bounds_.getNorthEast(),
+ );
+
+ if (this.div_ && sw && ne) {
+ this.div_.style.left = sw.x + "px";
+ this.div_.style.top = ne.y + "px";
+ this.div_.style.width = ne.x - sw.x + "px";
+ this.div_.style.height = sw.y - ne.y + "px";
+ }
+ }
+
+ // private addDragListeners(): void {
+ // let isDragging = false;
+ // let startX: number;
+ // let startY: number;
+ // let startBounds: google.maps.LatLngBounds;
+
+ // if (!this.div_) return;
+
+ // this.div_.addEventListener("mousedown", (e: MouseEvent) => {
+ // isDragging = true;
+ // startX = e.clientX;
+ // startY = e.clientY;
+ // startBounds = this.bounds_;
+ // e.preventDefault();
+ // });
+
+ // document.addEventListener("mousemove", (e: MouseEvent) => {
+ // if (!isDragging) return;
+
+ // const dx = e.clientX - startX;
+ // const dy = e.clientY - startY;
+
+ // const projection = this.getProjection();
+ // const sw = projection.fromLatLngToDivPixel(
+ // startBounds.getSouthWest(),
+ // );
+ // const ne = projection.fromLatLngToDivPixel(
+ // startBounds.getNorthEast(),
+ // );
+
+ // if (sw && ne) {
+ // const newSW = projection.fromDivPixelToLatLng(
+ // new google.maps.Point(sw.x + dx, sw.y + dy),
+ // );
+ // const newNE = projection.fromDivPixelToLatLng(
+ // new google.maps.Point(ne.x + dx, ne.y + dy),
+ // );
+
+ // if (newSW && newNE) {
+ // this.bounds_ = new google.maps.LatLngBounds(newSW, newNE);
+ // this.draw();
+ // }
+ // }
+ // });
+
+ // document.addEventListener("mouseup", () => {
+ // isDragging = false;
+ // });
+ // }
+
+ onRemove(): void {
+ if (this.div_ && this.div_.parentNode) {
+ this.div_.parentNode.removeChild(this.div_);
+ this.div_ = null;
+ }
+ }
+
+ getBounds(): google.maps.LatLngBounds {
+ return this.bounds_;
+ }
+
+ setBounds(bounds: google.maps.LatLngBounds): void {
+ this.bounds_ = bounds;
+ this.draw();
+ }
+ }
+
+ const worldBounds = new google.maps.LatLngBounds(
+ new google.maps.LatLng(-80, -180),
+ new google.maps.LatLng(80, 180),
+ );
+
+ const mask = new google.maps.Rectangle({
+ bounds: worldBounds,
+ fillColor: "darkgray",
+ fillOpacity: 1,
+ zIndex: 1,
+ });
+
+ if (tour.blank_map) mask.setMap(map);
+
+ const bounds = new google.maps.LatLngBounds(
+ new google.maps.LatLng(south, west),
+ new google.maps.LatLng(north, east),
+ );
+
+ const overlay = new DraggableGroundOverlay(
+ bounds,
+ tour.map_overlay.image_url,
+ );
+
+ overlay.setMap(map);
+
+ return () => {
+ overlay.setMap(null);
+ mask.setMap(null);
+ };
+ }, [map, tour, south, north, east, west]);
+
+ // useEffect(() => {
+ // if (!map) return;
+ // const worldBounds = new google.maps.LatLngBounds(
+ // new google.maps.LatLng(-85, -180),
+ // new google.maps.LatLng(85, 180),
+ // );
+
+ // const mask = new google.maps.GroundOverlay("/admin/blank.jpg", worldBounds);
+
+ // // Pass the click event through to the map
+ // const listener = mask.addListener(
+ // "click",
+ // (event: google.maps.MapMouseEvent) =>
+ // google.maps.event.trigger(map, "click", event),
+ // );
+ // if (tour.blank_map) {
+ // mask.setMap(map);
+ // }
+
+ // return () => {
+ // google.maps.event.removeListener(listener);
+ // mask.setMap(null);
+ // };
+ // }, [tour, map]);
+
+ // useEffect(() => {
+ // if (!map || !tour.map_overlay) return;
+ // if (!south || !north || !east || !west) return;
+
+ // const bounds = new google.maps.LatLngBounds(
+ // new google.maps.LatLng(south, west),
+ // new google.maps.LatLng(north, east),
+ // );
+
+ // const overlay = new google.maps.GroundOverlay(
+ // tour.map_overlay.image_url,
+ // bounds,
+ // );
+
+ // // Pass the click event through to the map
+ // const listener = overlay.addListener(
+ // "click",
+ // (event: google.maps.MapMouseEvent) =>
+ // google.maps.event.trigger(map, "click", event),
+ // );
+
+ // overlay.setMap(map);
+ // const mapBounds = map.getBounds();
+ // if (mapBounds) {
+ // map.fitBounds(mapBounds.extend(new google.maps.LatLng(south, west)));
+ // map.fitBounds(mapBounds.extend(new google.maps.LatLng(north, east)));
+ // }
+
+ // return () => {
+ // google.maps.event.removeListener(listener);
+ // overlay.setMap(null);
+ // };
+ // }, [tour, map, south, north, east, west]);
+
+ if (!tour.map_overlay || !south || !north || !east || !west || !editable)
+ return <>>;
+
+ return <>>;
+};
+
+export default MapOverlay;
diff --git a/app/components/map/MapOverlayRectangle.tsx b/app/components/map/MapOverlayRectangle.tsx
new file mode 100644
index 00000000..5db30722
--- /dev/null
+++ b/app/components/map/MapOverlayRectangle.tsx
@@ -0,0 +1,135 @@
+import { useMap } from "@vis.gl/react-google-maps";
+import { useContext, useEffect, useRef } from "react";
+import { FeedbackContext, OverlayContext, TourContext } from "~/contexts";
+import { debounce } from "~/utils/debounce";
+import { sendUpdate } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+
+const MapOverlayRectangle = () => {
+ const map = useMap();
+ const { tour, setIsSaving } = useContext(TourContext);
+ const { setFeedback } = useContext(FeedbackContext);
+ const {
+ south,
+ north,
+ east,
+ west,
+ setSouth,
+ setNorth,
+ setEast,
+ setWest,
+ draggable,
+ } = useContext(OverlayContext);
+ const rectangleRef = useRef(undefined);
+
+ useEffect(() => {
+ if (!map) return;
+ if (!south || !north || !east || !west) return;
+
+ const handleDrag = debounce(async () => {
+ if (!rectangleRef.current) return;
+ const newBounds = rectangleRef.current.getBounds();
+ if (!newBounds) return;
+
+ setIsSaving(true);
+ if (setSouth) setSouth(newBounds.getSouthWest().lat());
+ if (setWest) setWest(newBounds.getSouthWest().lng());
+ if (setNorth) setNorth(newBounds.getNorthEast().lat());
+ if (setEast) setEast(newBounds.getNorthEast().lng());
+
+ const { response, data } = await sendUpdate({
+ tenant: tour.tenant,
+ record: tour.map_overlay.id,
+ body: {
+ model: "map_overlay",
+ map_overlay: {
+ south: newBounds.getSouthWest().lat(),
+ west: newBounds.getSouthWest().lng(),
+ north: newBounds.getNorthEast().lat(),
+ east: newBounds.getNorthEast().lng(),
+ },
+ reindex: {
+ id: tour.id,
+ model: "tour",
+ },
+ },
+ });
+
+ if (!response.ok) {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not save the map overlay position."),
+ });
+ }
+
+ setIsSaving(false);
+ }, 300);
+
+ map.setOptions({ gestureHandling: "greedy" });
+
+ rectangleRef.current = new google.maps.Rectangle({
+ bounds: new google.maps.LatLngBounds(
+ new google.maps.LatLng(south, west),
+ new google.maps.LatLng(north, east),
+ ),
+ map,
+ draggable,
+ strokeColor: "deeppink",
+ fillOpacity: 0,
+ editable: !draggable,
+ clickable: true,
+ zIndex: 10,
+ });
+
+ let dragEndListener: google.maps.MapsEventListener | undefined,
+ boundsListener: google.maps.MapsEventListener | undefined;
+
+ if (draggable) {
+ dragEndListener = google.maps.event.addListener(
+ rectangleRef.current,
+ "dragend",
+ handleDrag,
+ );
+ } else {
+ boundsListener = google.maps.event.addListener(
+ rectangleRef.current,
+ "bounds_changed",
+ handleDrag,
+ );
+ }
+
+ return () => {
+ if (rectangleRef.current) {
+ rectangleRef.current.setMap(null);
+ rectangleRef.current = undefined;
+ }
+ if (boundsListener) boundsListener.remove();
+ if (dragEndListener) dragEndListener.remove();
+ };
+ }, [
+ map,
+ south,
+ east,
+ north,
+ west,
+ setSouth,
+ setWest,
+ setNorth,
+ setEast,
+ tour,
+ setIsSaving,
+ setFeedback,
+ draggable,
+ ]);
+
+ // useEffect(() => {
+ // if (!rectangleRef.current || !map) return;
+ // rectangleRef.current.setOptions({ clickable: true, draggable });
+ // draggableRef.current = draggable;
+ // map.setOptions({ gestureHandling: draggable ? "none" : "auto" });
+ // }, [draggable, map]);
+
+ return <>>;
+};
+
+export default MapOverlayRectangle;
diff --git a/app/components/map/MarkerStyle.tsx b/app/components/map/MarkerStyle.tsx
new file mode 100644
index 00000000..90913d6d
--- /dev/null
+++ b/app/components/map/MarkerStyle.tsx
@@ -0,0 +1,129 @@
+import { useContext, useState } from "react";
+import {
+ FeedbackContext,
+ FormContext,
+ RelatedContext,
+ StopMapContext,
+ TourContext,
+} from "~/contexts";
+import TextInput from "../inputs/TextInput";
+import FileUpload from "../inputs/FileUpload";
+import { Saving } from "../Saving";
+import DeleteButton from "../buttons/DeleteButton";
+import ToolTip from "../inputs/ToolTip";
+import IconModal from "../stops/IconModal";
+import { sendUpdate } from "~/utils/requests";
+import { getErrorMessage } from "~/utils/errors";
+import type { TServerResponse, TStop } from "~/types";
+
+const MarkerStyle = () => {
+ const context = useContext(StopMapContext);
+ if (!context) throw new Error("StopMapContext is undefined");
+ const { iconColor, mapIcon, setIconColor, setMapIcon, stop } = context;
+ const { tour, setIsSaving } = useContext(TourContext);
+ const { setFeedback } = useContext(FeedbackContext);
+ const [upLoading, setUpLoading] = useState(false);
+ const [iconModalOpen, setIconModalOpen] = useState(false);
+
+ const updateIconColor = (data: TServerResponse) => {
+ if (setIconColor) setIconColor((data as TStop).icon_color);
+ };
+
+ const iconAdded = (data: unknown) => {
+ if (setMapIcon) setMapIcon((data as TStop).map_icon);
+ setUpLoading(false);
+ setIsSaving(false);
+ };
+
+ const handleDelete = async () => {
+ if (!tour) return;
+ const { response, data } = await sendUpdate({
+ tenant: tour.tenant,
+ record: stop.id,
+ body: {
+ model: "stop",
+ stop: { map_icon: null },
+ reindex: {
+ model: "tour",
+ id: tour.id,
+ },
+ },
+ });
+ if (response.ok && setMapIcon) {
+ setMapIcon(undefined);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not remove the custom icon."),
+ });
+ }
+ };
+
+ return (
+
+
+
+
+ {upLoading ? (
+
+ ) : (
+ {
+ setUpLoading(true);
+ setIsSaving(true);
+ }}
+ className="text-blue-500 hover:underline hover:text-blue-700 cursor-pointer"
+ >
+
+ Upload New Icon{" "}
+
+ Upload a custom icon for this stop. The file must be an image
+ (jpeg, png, or webp) and no larger than 80 pixels by 80
+ pixels. Once uploaded, the icon be used on other stops by
+ clicking the "Use Existing Icon" link below.
+
+
+
+ )}
+
+
+
+ Reuse previously added icon.
+
+
+ {mapIcon && (
+
+
+
+ Remove Custom Icon
+
+ This will only remove the from this stop.
+
+
+ )}
+
+
+ );
+};
+
+export default MarkerStyle;
diff --git a/app/components/map/ParkingMarker.tsx b/app/components/map/ParkingMarker.tsx
new file mode 100644
index 00000000..58a04493
--- /dev/null
+++ b/app/components/map/ParkingMarker.tsx
@@ -0,0 +1,82 @@
+import { faSquare, faSquareParking } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ AdvancedMarker,
+ useAdvancedMarkerRef,
+ useMapsLibrary,
+} from "@vis.gl/react-google-maps";
+import { useContext, useEffect } from "react";
+import { StopMapContext } from "~/contexts";
+
+const ParkingMarker = () => {
+ const geocoderLib = useMapsLibrary("geocoding");
+ const context = useContext(StopMapContext);
+
+ if (!context) {
+ throw new Error("StopMapContext is undefined");
+ }
+
+ const {
+ parkingLat,
+ parkingLng,
+ setParkingLat,
+ setParkingLng,
+ setParkingAddress,
+ } = context;
+
+ const [markerRef, marker] = useAdvancedMarkerRef();
+
+ useEffect(() => {
+ if (marker && (!parkingLat || !parkingLng)) marker.map = null;
+ }, [marker, parkingLat, parkingLng]);
+
+ const handleDragEnd = (event: google.maps.MapMouseEvent) => {
+ if (!event.latLng) return;
+
+ const newLat = event.latLng?.lat();
+ const newLng = event.latLng?.lng();
+
+ if (setParkingLat) setParkingLat(newLat);
+ if (setParkingLng) setParkingLng(newLng);
+
+ if (geocoderLib) {
+ const locater = new geocoderLib.Geocoder();
+ locater.geocode(
+ {
+ location: { lat: newLat, lng: newLng },
+ },
+ (result, status) => {
+ if (status === "OK" && result) {
+ if (setParkingAddress)
+ setParkingAddress(result[0].formatted_address);
+ } else {
+ console.error(status);
+ }
+ },
+ );
+ } else {
+ // TODO: warn unable to update address.
+ }
+ };
+
+ if (parkingLat && parkingLng) {
+ return (
+
+
+
+
+ );
+ }
+
+ return <>>;
+};
+
+export default ParkingMarker;
diff --git a/app/components/map/StopMap.tsx b/app/components/map/StopMap.tsx
new file mode 100644
index 00000000..f23468ce
--- /dev/null
+++ b/app/components/map/StopMap.tsx
@@ -0,0 +1,124 @@
+import { APIProvider, Map } from "@vis.gl/react-google-maps";
+import { useContext, useState } from "react";
+import { OverlayContext, StopMapContext, TourContext } from "~/contexts";
+import ClientOnly from "../ClientOnly";
+import StopMarker from "./StopMarker";
+import ParkingMarker from "./ParkingMarker";
+import Location from "./Location";
+import MarkerStyle from "./MarkerStyle";
+import MapOverlay from "./MapOverlay.client";
+import ParkingDisclosure from "../stops/ParkingDisclosure";
+import type { ReactNode } from "react";
+import type { TStop } from "~/types";
+
+const StopMap = ({ stop, children }: { stop: TStop; children?: ReactNode }) => {
+ const [lat, setLat] = useState(stop.lat);
+ const [lng, setLng] = useState(stop.lng);
+ const [parkingLat, setParkingLat] = useState(
+ stop.parking_lat,
+ );
+ const [parkingLng, setParkingLng] = useState(
+ stop.parking_lng,
+ );
+ const [address, setAddress] = useState(stop.address);
+ const [parkingAddress, setParkingAddress] = useState(
+ stop.parking_address,
+ );
+ const [mapIcon, setMapIcon] = useState(stop.map_icon);
+ const [iconColor, setIconColor] = useState(
+ stop.icon_color,
+ );
+ const [position, setPosition] = useState(stop.position);
+ const { tour } = useContext(TourContext);
+
+ if (stop) {
+ return (
+
+
+
+
+
+ {lat && lng && (
+
+
+
+ )}
+
+
+
+
+ );
+ }
+
+ return <>>;
+};
+
+export default StopMap;
diff --git a/app/components/map/StopMarker.tsx b/app/components/map/StopMarker.tsx
new file mode 100644
index 00000000..c6c1211a
--- /dev/null
+++ b/app/components/map/StopMarker.tsx
@@ -0,0 +1,46 @@
+import { useContext } from "react";
+import MapMarker from "./MapMarker";
+import { StopMapContext } from "~/contexts";
+import { useMapsLibrary } from "@vis.gl/react-google-maps";
+
+const StopMarker = () => {
+ const context = useContext(StopMapContext);
+ const geocoderLib = useMapsLibrary("geocoding");
+
+ if (!context) {
+ throw new Error("StopMapContext is undefined");
+ }
+ const { setLat, setLng, setAddress } = context;
+
+ const handleDragEnd = (event: google.maps.MapMouseEvent) => {
+ if (!event.latLng) return;
+
+ const newLat = event.latLng?.lat();
+ const newLng = event.latLng?.lng();
+
+ if (setLat) setLat(newLat);
+ if (setLng) setLng(newLng);
+
+ if (geocoderLib) {
+ const locater = new geocoderLib.Geocoder();
+ locater.geocode(
+ {
+ location: { lat: newLat, lng: newLng },
+ },
+ (result, status) => {
+ if (status === "OK" && result) {
+ if (setAddress) setAddress(result[0].formatted_address);
+ } else {
+ console.error(status);
+ }
+ },
+ );
+ } else {
+ // TODO: warn unable to update address.
+ }
+ };
+
+ return ;
+};
+
+export default StopMarker;
diff --git a/app/components/map/TourMap.client.tsx b/app/components/map/TourMap.client.tsx
new file mode 100644
index 00000000..2ccbf51a
--- /dev/null
+++ b/app/components/map/TourMap.client.tsx
@@ -0,0 +1,70 @@
+import { Map, useMap } from "@vis.gl/react-google-maps";
+import { useContext, useEffect } from "react";
+import { TourContext, StopMapContext } from "~/contexts";
+import MapMarker from "./MapMarker";
+import type { ReactNode } from "react";
+
+const TourMap = ({ children }: { children: ReactNode }) => {
+ const { tour } = useContext(TourContext);
+ const map = useMap();
+
+ useEffect(() => {
+ if (!map || !tour.bounds) return;
+
+ map.fitBounds({
+ east: tour.bounds.east,
+ south: tour.bounds.south,
+ north: tour.bounds.north,
+ west: tour.bounds.west,
+ });
+ }, [map, tour]);
+
+ return (
+
+ );
+};
+
+export default TourMap;
diff --git a/app/components/media_grid/Embed.tsx b/app/components/media_grid/Embed.tsx
new file mode 100644
index 00000000..27c5914a
--- /dev/null
+++ b/app/components/media_grid/Embed.tsx
@@ -0,0 +1,139 @@
+import { useContext, useEffect, useState } from "react";
+import { parseEmbedUrl } from "~/utils/embed_parser";
+import TextInput from "../inputs/TextInput";
+import {
+ ErrorContext,
+ FeedbackContext,
+ RecordContext,
+ RelatedContext,
+ TourContext,
+} from "~/contexts";
+import type { TEmbedProvider, TMedium } from "~/types";
+import { sendCreate } from "~/utils/requests";
+import { joinImage } from "~/utils/image_upload";
+import { getErrorMessage } from "~/utils/errors";
+
+interface Props {
+ onSuccess: (data: TMedium) => void;
+}
+const Embed = ({ onSuccess }: Props) => {
+ const [embedUrl, setEmbedUrl] = useState(undefined);
+ const [embedCode, setEmbedCode] = useState(undefined);
+ const [link, setLink] = useState(undefined);
+ const [provider, setProvider] = useState(
+ undefined,
+ );
+ const { tour } = useContext(TourContext);
+ const { recordId, recordModel } = useContext(RecordContext);
+ const { relatedModel, relatedType } = useContext(RelatedContext);
+ const { setError } = useContext(ErrorContext);
+ const { setFeedback } = useContext(FeedbackContext);
+
+ useEffect(() => {
+ if (link) {
+ const result = parseEmbedUrl(link);
+ if (result) {
+ setEmbedUrl(result.embedUrl);
+ setEmbedCode(result.embedCode);
+ setProvider(result.provider);
+ }
+ }
+
+ return () => {
+ setEmbedUrl(undefined);
+ setEmbedCode(undefined);
+ setProvider(undefined);
+ };
+ }, [link]);
+
+ const handleInput = (value: string) => {
+ setLink(value);
+ };
+
+ const addMedium = async () => {
+ setFeedback({
+ message: `Adding embed from ${provider?.toLocaleUpperCase()}`,
+ type: "success",
+ });
+ const body = {
+ medium: {
+ filename: provider === "unknown" ? null : `${embedCode}.jpg`,
+ embed_id: embedCode,
+ video_provider: provider,
+ },
+ model: "medium",
+ reindex: {
+ model: "tour",
+ id: tour.id,
+ },
+ };
+ const { response: createResponse, data: createData } = await sendCreate({
+ tenant: tour.tenant,
+ body,
+ });
+
+ if (createResponse.ok) {
+ const { response, data } = await joinImage({
+ relatedType,
+ recordModel,
+ relatedModel,
+ recordId,
+ imageId: createData.id,
+ tenant: tour.tenant,
+ });
+ if (response.ok && onSuccess) {
+ setFeedback(undefined);
+ onSuccess(data);
+ setEmbedUrl(undefined);
+ setEmbedCode(undefined);
+ setProvider(undefined);
+ setLink(undefined);
+ } else {
+ setFeedback({
+ type: "error",
+ message: getErrorMessage(data, "Could not add this embed to the tour."),
+ });
+ }
+ } else {
+ setError(getErrorMessage(createData, "Could not create this embed."));
+ }
+ };
+
+ return (
+
+
+
+ {embedUrl && (
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default Embed;
diff --git a/app/components/media_grid/FileDrop.tsx b/app/components/media_grid/FileDrop.tsx
new file mode 100644
index 00000000..f8e04e78
--- /dev/null
+++ b/app/components/media_grid/FileDrop.tsx
@@ -0,0 +1,84 @@
+import { useContext, useState } from "react";
+import { RecordContext, RelatedContext, TourContext } from "~/contexts";
+import { imageUpload, joinImage } from "~/utils/image_upload";
+import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
+
+interface Props {
+ onSuccess: (args: unknown) => void;
+ children: ReactNode;
+ fileSaving: string | undefined;
+ setFileSaving: Dispatch>;
+}
+
+const FileDrop = ({
+ onSuccess,
+ children,
+ fileSaving,
+ setFileSaving,
+}: Props) => {
+ const { tour, setIsSaving } = useContext(TourContext);
+ const { recordId, recordModel } = useContext(RecordContext);
+ const { relatedModel, relatedType } = useContext(RelatedContext);
+ const [isOver, setIsOver] = useState(false);
+
+ const handleDrop = async (event: DragEvent) => {
+ event.preventDefault();
+ setIsOver(false);
+
+ for (const file of event.dataTransfer.files) {
+ setFileSaving(file.name);
+ setIsSaving(true);
+ const { response: uploadResponse, data: uploadData } = await imageUpload({
+ tenant: tour.tenant,
+ file,
+ });
+ if (relatedModel && relatedType && uploadResponse.ok) {
+ const { response, data } = await joinImage({
+ relatedType,
+ recordModel,
+ relatedModel,
+ recordId,
+ imageId: uploadData.id,
+ tenant: tour.tenant,
+ });
+ if (response.ok && onSuccess) {
+ onSuccess(data);
+ }
+ }
+ }
+ setFileSaving(undefined);
+ setIsSaving(false);
+ };
+
+ const handleDragOver = (event: DragEvent) => {
+ setIsOver(true);
+ event.preventDefault();
+ };
+
+ return (
+ setIsOver(false)}
+ >
+
+ {fileSaving ? (
+ UPLOADING: {fileSaving}
+ ) : (
+ <>
+ {isOver ? (
+ <>Drop to upload>
+ ) : (
+ <>
+ Drag and drop images onto this area to upload them or {children}
+ >
+ )}
+ >
+ )}
+
+
+ );
+};
+
+export default FileDrop;
diff --git a/app/components/media_grid/MediaGrid.tsx b/app/components/media_grid/MediaGrid.tsx
new file mode 100644
index 00000000..c20f4359
--- /dev/null
+++ b/app/components/media_grid/MediaGrid.tsx
@@ -0,0 +1,224 @@
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ rectSortingStrategy,
+} from "@dnd-kit/sortable";
+import { useContext, useEffect, useRef, useState } from "react";
+import { sendDelete, sendUpdate } from "~/utils/requests";
+import {
+ FeedbackContext,
+ FormContext,
+ RecordContext,
+ RelatedContext,
+ TourContext,
+} from "~/contexts";
+import SortableMedium from "./SortableMedium";
+import FileDrop from "./FileDrop";
+import Embed from "./Embed";
+import FileUpload from "../inputs/FileUpload";
+import ToolTip from "../inputs/ToolTip";
+import ReuseMedia from "../ReuseMedia";
+import { getErrorMessage } from "~/utils/errors";
+import type { DragEndEvent } from "@dnd-kit/core";
+import type { TMedium } from "~/types";
+
+const MediaGrid = ({ media }: { media: TMedium[] }) => {
+ const [items, setItems] = useState(media);
+ // Edits applied locally (via itemUpdated) that the server hasn't
+ // confirmed yet — a `media` prop refresh triggered by an unrelated
+ // revalidate can arrive before the ES-backed read reflects this edit,
+ // and would otherwise clobber it back to the stale value.
+ const pendingUpdatesRef = useRef