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. + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](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 ( + <> +
+ + {({ open }) => ( + <> + + + Select specific tour{" "} + + + + + {open && ( + + + <> + {siteTours?.map((tour) => { + return ( +
+ + {({ checked }) => ( + + )} + + +
+ ); + })} + +
+
+ )} +
+ + )} +
+
+

+ 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 ( + <> + + setShow(false)} + > + {" "} +
+
+ + Create New Site + + + + +
+ + +
+
+
+
+
+ + ); +}; + +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 ( + { + if (setError) setError(undefined); + }} + > + +
+ + Error + {error} +

Please try again. Contact ECDS if the issue continues.

+
+ +
+
+
+
+ ); +}; + +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 ( + {}}> + +
+ +

+ {feedback?.message} +

+ {(feedback?.type == "error" || feedback?.dismissable) && ( +
+ +
+ )} +
+
+
+ ); +}; + +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 ( + <> + + setIsOpen(false)} + className="relative z-50" + > + +
+
+ +
+ + DONE + +
+ + Admins and Tour Authors for{" "} + {tourSet.name} + + + + {({ open }) => ( + <> + + Below is a list of admins and tour authors.{" "} + + More{" "} + + + + + {open && ( + + +
+
+

Admins can:

+
    +
  • + Create, delete, and publish tours and + related content +
  • +
  • Approve new site admins
  • +
  • Approve new tour authors
  • +
  • View unpublished tours
  • +
+
+
+

+ Tour Authors can edit specific tour content + including: +

+
    +
  • Create, delete, and edit media
  • +
  • Create, delete, and edit flat pages
  • +
  • + Approve new tour map options and overlays +
  • +
+
+
+
+
+ )} +
+ + )} +
+
+

Admins

+ + + + + + + + + + {tourSet.admins.map((admin, index) => { + return ( + + + + + + ); + })} + +
AdminChange AccessRevoke
{admin.display_name} + + + +
+

Tour Authors

+ + + + + + + + + + {tourSet.tour_authors.map((author, index) => { + return ( + + + + + + ); + })} + +
AuthorTourRevoke
{author.user}{author.tour} + +
+
+
+
+
+ + ); +}; + +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 ( + <> + + setAccessRequestModalOpen(false)} + className="relative z-50" + > + +
+
+ +
+ + DONE + +
+ + Pending Access Requests + + + Below are pending requests for full access to this tour site. + You can limit the person's access to a specific tour by + selecting it in the dropdown before clicking approve. + + + + + + + + + + + + + {accessRequests.map((request, index) => { + return ( + + ); + })} + +
RequesterEmailTourDenyApprove
+
+
+
+
+ + ); +}; + +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 ( + <> + setIsOpen(false)} + className="relative z-50" + > +
+ +
+ {/* */} + Other Available {model} + {/* */} + + + Click to Add {model} to Current Tour or Stop + +
+
+
+ + + + + + + + + + + {items?.map((item) => { + return ( + + + + + + + ); + })} + + +
+ {model} + + Add{" "} + + Add a stop from another tour. If you edit the stop in + one tour, the changes will appear in other tours. If + you would like to add the stop but only have changes + affect a signal tour, use the copy function. + + + Copy{" "} + + Copy a stop from another tour. if you edit the stop in + one tour, the changes will not appear in other tours. + If you would like to have edits apply to other tours, + use the add function. + + + Delete{" "} + + You can only delete a stop if it is not part of tour. + +
+ {item.title} + + + + + + + Delete + +
+ {paginationLinks && ( + + )} +
+
+
+
+
+ + ); +}; + +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 ( + <> + setIsOpen(false)} + className="relative z-50" + > +
+ +
+ + Other Available Media + + + + Click to Add Media to Current Tour or Stop + +
+
+
+ {media?.map((medium) => { + return ( + + ); + })} +
+ {paginationLinks && ( + + )} +
+
+
+
+ + ); +}; + +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 ( + setIsOpen(false)} + className="relative z-50" + > +
+ +
+ {title} + + {description && ( + {description} + )} +
+
+
{children}
+
+
+
+
+ ); +}; + +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 ( + {}} + className="z-10" + > + +
+
+ + + OpenTour Builder Terms of Service + +

Last modified April 26, 2022

+

+ Please contact ECDS at ecds@emory.edu with any questions about + copyright infringement. +

+

1. Introduction

+

+ Thank you for using OpenTour Builder. OpenTour Builder + ("OpenTour") is a service made available by the Emory + Center for Digital Scholarship ("ECDS", "we" + or "us"), which is part of Emory University and is + located at 540 Asbury Circle, Atlanta, Georgia 30322, USA. These + OpenTour Builder Terms of Service (which we refer to as the + "Terms") are a legally binding agreement between you and + Emory University. They cover your use of and access to OpenTour + Builder and information you personally create or supply that is + entered into OpenTour Builder ("Content"). Your use of + OpenTour Builder requires that you agree to the Terms. Please read + them carefully. If you do not understand the Terms, or do not + accept any part of them, then you should not use OpenTour Builder. + By using OpenTour Builder, you are agreeing to the Terms. +

+

+ 2. Your Use of OpenTour Builder +

+

+ Your OpenTour Account. You will need an OpenTour Account in order + to use the tour creation features of OpenTour. In order to create + an OpenTour Account you will need to authorize ECDS to securely + connect to your social media account (Google) which will confirm + your ownership of your social media account and provide the ECDS + with a token that enables you to securely log in to OpenTour. To + protect your OpenTour Account, keep your connected social media + account passwords confidential. +

+

+ Your Tour Site Access. All tours in OpenTour are created within a + Tour Site, a set of tours at a single URL subdomain ("Tour + Site"). You will need direct contact with an OpenTour admin + in order to access a Tour Site and be able to create tours. The + OpenTour administrator will add permissions to your OpenTour + Account so that you can access a Tour Site, edit Tours, and make + public ("publish") Tours. +

+

+

+ Your Conduct. Don't misuse OpenTour. You may use OpenTour + only as permitted by law and these Terms. You are responsible for + your conduct and your Content stored in OpenTour and shared + publicly. Your Content should not include any material that is + illegal or that infringes or otherwise violates the rights of + anyone else, including any rights they may have under the law of + copyrights, trademarks, trade secrets, or privacy. Your Content + should not be defamatory. ECDS may review your conduct and Content + in OpenTour for compliance with the Terms. You are responsible for + the activity that happens on or through your OpenTour Account and + Content on your Tour Site. ECDS reserves the right to remove any + Content that does not comply with these Terms or for any other + reason. +

+

+ Suspension and Termination. You can stop using OpenTour at any + time. We may suspend or permanently disable your access to + OpenTour if you violate our Terms or policies or for any other + reason. We will attempt to give you notice of any suspension or + disabling of your access to OpenTour based on the contact + information available to us, but (i) any such notice may arrive + after suspension or disabling has occurred; (ii) any failure to + receive notice does not limit our right to disable or suspend your + account; and (iii) we may suspend or disable your access to + OpenTour without notice if you are using OpenTour in a manner that + could cause us legal liability or disrupt other users' + ability to access and use OpenTour. We respond to notices of + alleged copyright infringement and will remove Content and + terminate accounts of infringers in accordance with the U.S. + Digital Millennium Copyright Act. +

+

+ Emails. ECDS receives your email address through your connected + social media account. In connection with your use of OpenTour + Builder, we may send you information related to OpenTour Builder + functions or updates. +

+

3. Your Content

+

+ Ownership, Licenses. OpenTour Builder allows you to create, + modify, delete, and link to or iframe Content. You retain + ownership of any intellectual property rights that you hold in + that Content. When you create, upload, submit, store, send, or + receive Content to or through OpenTour, you give ECDS (and those + we work with) a worldwide license to use, host, store, reproduce, + modify, create derivative works (such as those resulting from + adaptations or other changes we make so that your Content works + better with OpenTour), communicate, publish, publicly perform, + publicly display, and distribute such Content. The rights you + grant in this license are for the limited purpose of maintaining, + operating, promoting, and improving OpenTour and its features. + This license continues even if you stop using OpenTour unless you + delete your Content. Make sure you have the necessary rights to + grant us this license for any Content that you submit to OpenTour. +

+ +

+ Sharing Your Content. Sharing settings in OpenTour allow you to + share ("publish") your Content for the public to view. + You can also share access to your Tour Site admin, allowing other + OpenTour users to add to, modify, and delete your Content. The + sharing features of OpenTour are a critical part of the OpenTour + app, but please understand that they may enable third parties to + use and access your Content in ways that ECDS cannot control or + undo. +

+ +

+ Privacy, Data Security. Any Content you submit to OpenTour will be + stored and may be accessible to site administrators, who may + access and use your Content for the purposes described in the + license above. +

+ +

+ Your Content will not be publicly accessible unless you elect to + make your Content public. Before you choose to make your Content + publicly accessible, please be sure you understand the + consequences. Third parties will be able to access, reproduce, and + use any Content you make public, and ECDS cannot undo or exercise + any control over such activity. +

+ +

+ The regular operation of OpenTour will not make your Content + public unless you choose to do so. We are not responsible for + ensuring the privacy of your Content and we are unable to + guarantee that your Content will remain stable, uncorrupted, + functional, or error free. +

+ +

+ OpenTour collects and stores basic information about your social + media account profile when you create your OpenTour Account. + OpenTour only uses this information to facilitate your ability to + securely log in to OpenTour and will not make it public. +

+ +

+ Indemnification. You will hold harmless, defend, and indemnify + Emory University and its affiliates, officers, agents, and + employees from any claim, suit, or action arising from or related + to your use of OpenTour, any Content, or any violation of these + Terms, including any liability or expense arising from claims, + losses, damages, suits, judgments, litigation costs, and + attorneys' fees. +

+ +

+ 4. About Software in our Services +

+ +

+ OpenTour Builder is open source software licensed under the MIT + License. ECDS promotes the development and use of open source + software and invites collaboration in the further development of + OpenTour Builder. You can download and install OpenTour and see + our license from the ECDS GitHub account. +

+ +

5. Modifying and Terminating

+ +

+ OpenTour Changes to OpenTour or these Terms. We are continuing to + improve and develop OpenTour. You acknowledge that the form and + nature of OpenTour may change from time to time. We will endeavor + to provide public notice of material changes to OpenTour that we + reasonably believe will adversely impact your use of OpenTour. + However, there are times when we will make changes to OpenTour + without giving notice. +

+ +

+ Discontinuation of OpenTour. We reserve the right to discontinue + development or maintenance of OpenTour. We also reserve the right + to discontinue this OpenTour site (https://opentour.site). Contact + ecds@emory.edu if you have questions about preserving your Content + in OpenTour in the event of discontinuation. +

+

+ 6. Warranties, Disclaimers, and Limitations on Liability +

+ +

+ A great deal of work has gone into the creation of OpenTour, and + we are regularly working to maintain and improve OpenTour. But + there are certain things that we don't promise about + OpenTour. +

+ +

+ OTHER THAN AS EXPRESSLY SET OUT IN THESE TERMS, NEITHER EMORY + UNIVERSITY NOR ANY OF ITS AFFILIATED ENTITIES MAKE ANY SPECIFIC + PROMISES ABOUT OPENTOUR. FOR EXAMPLE, WE DON'T MAKE ANY + COMMITMENTS ABOUT THE CONTENT WITHIN OPENTOUR, THE SPECIFIC + FUNCTIONS OF OPENTOUR, OR THEIR RELIABILITY, AVAILABILITY, OR + ABILITY TO MEET YOUR NEEDS. WE PROVIDE OPENTOUR “AS IS.” TO THE + EXTENT PERMITTED BY LAW, WE EXCLUDE AND DISCLAIM ALL WARRANTIES. +

+ +

+ NEITHER EMORY UNIVERSITY NOR ANY OF ITS AFFILIATED ENTITIES WILL + BE RESPONSIBLE FOR (1) ANY LOST PROFITS, REVENUES, OR FINANCIAL + LOSSES, (2) ANY LOST, DESTROYED, OR CORRUPTED DATA OR INFORMATION, + (3) ANY DATA OR INFORMATION THAT IS ACCESSED BY THIRD PARTIES + WITHOUT AUTHORIZATION, OR (4) ANY INDIRECT, SPECIAL, + CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. +

+ +

+ TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF EMORY + UNIVERSITY AND ANY OF ITS AFFILIATED ENTITIES, FOR ANY CLAIMS + UNDER THESE TERMS OR ANY CLAIM ARISING FROM YOUR USE OF OPENTOUR, + INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE AMOUNT YOU + PAID US TO USE OPENTOUR (OR, IF WE CHOOSE, TO SUPPLYING YOU WITH + OPENTOUR AGAIN). +

+ +

+ IN ALL CASES, EMORY UNIVERSITY AND ALL ITS AFFILIATED ENTITIES + WILL NOT BE LIABLE FOR ANY LOSS OR DAMAGE THAT IS NOT REASONABLY + FORESEEABLE. +

+ +

7. Laws Governing the Terms

+ +

+ The laws of the State of Georgia, USA, excluding Georgia's + conflict of laws rules, will apply to any disputes arising out of + or relating to these Terms or any other matter relating to your + use of OpenTour. All claims arising out of or relating to these + Terms or OpenTour must be litigated exclusively in the state + courts of DeKalb County, Georgia, USA, or the federal courts in + the United States District Court for the Northern District of + Georgia. You and Emory consent to personal jurisdiction in those + courts and waive any objections based on improper or inconvenient + forum. +

+ +

8. About these Terms

+ +

+ We may modify these Terms and supply additional terms that apply + to OpenTour. Changes may be needed for a variety of reasons, such + as: new features or other modifications of OpenTour; changes in + law, custom, or political or economic policy; new guidelines + issued by regulators or relevant industry bodies; or the + operational, compliance, or other needs or obligations of ECDS. + We'll post notice of modifications or additions to these + Terms on this page. You should visit this page regularly to check + for updates. We will make every effort to provide you with advance + notice of any material changes or additions to these terms. + Modifications or additions to these Terms will go into effect as + of the date indicated on this page, and some changes may be + effective immediately. If you do not agree to any new or modified + terms, you should discontinue your use of OpenTour. These Terms + control the relationship between ECDS and you. They do not create + any third party beneficiary rights. If you do not comply with + these Terms, and we don't take action right away, this + doesn't mean that we are giving up any rights that we may + have (such as taking action in the future). If it turns out that a + particular term is not enforceable, this will not affect any other + terms. +

+ +

+ For information about how to contact ECDS, please visit our + contact page. +

+
+ +
+
+
+
+
+ ); +}; + +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 + {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 ( + <> + + +
+
+ + + Manage Tour Authors + +

+ Not yet implemented. +

+
+ +
+
+
+
+
+ + ); + } + + 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} + + + + + + {handleDelete && } + + + + {tours.map((tour, index) => ( + + + + {handleDelete && ( + + )} + + ))} + +
{heading} + Published{" "} + + Until published, the tour will only be visible when signed in to + OpenTour. + + Delete
+ + {tour.title} + + + + {tour.published_on ? ( + <>{tour.published_on} + ) : ( + + )} + + + + <> + +
+
+ ); +}; + +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} + + + + + {handleDelete && } + + + + {items.map((tourSet, index) => ( + + + {handleDelete && ( + + )} + + ))} + +
{heading}Delete
+ + {tourSet.name} + + + +
+
+ ); +}; + +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 ( + +
+ Travel Modes + + + + + + + + + + {modes.map((mode) => { + return ( + + {saving === mode.id ? ( + + ) : ( + <> + + + + + )} + + ); + })} + +
+ Mode + + Enable + + Default +
+ + + {mode.title} + + handleToggle(mode)} + checked={tourModes.includes(mode.id)} + > + + + + + handleDefault(mode)} + disabled={!tourModes.includes(mode.id)} + /> +
+
+
+ ); +}; + +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 ( +
+ + + +
{tourSet.name}
+
+ ); +}; + +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 ( + <> + + setAskConfirm(false)} + className="relative z-50" + > + +
+ + + {description ?? `Remove this ${removing}?`} + +

+ {message ?? `Are you sure you want to remove this ${removing}`}{" "} + from the tour. +

+
+ + +
+
+
+
+ + ); +}; + +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" && ( +