diff --git a/lab13.md b/lab13.md new file mode 100644 index 0000000..6c1d78d --- /dev/null +++ b/lab13.md @@ -0,0 +1,261 @@ +Create a new package for your SPA +We are going to refactor the current proto considerably in order to turn it into an SPA, and you may want to refer back to it occasionally to see how it worked. You may even want to continue doing some explorations there. It’s probably gotten a little cluttered by now, which is fine, because that made it easier to explore without having to make it “complete”. So let’s keep proto around. + +Instead start working on your SPA in fresh package, which we’ll call app. + +Do you remember the drill? Here are the steps: + +Create a new directory, packages/app +Run npm init in the app directory +Install dev dependences in app: npm install -D tsc vite +Install runtime dependences in app: npm install lit @calpoly/mustang +Copy your tsconfig.json file from proto to app +Create a public directory, with subdirectories for styles and icons +don’t copy all the CSS files just yet, we will pick and choose +you can copy your SVG icon sprite(s) from proto/public/icons +Create your SPA’s index.html +We’re going to start by copying index.html (the one that’s not in public) from proto. Yours should already have in the , as well as a some HTML, including your app’s header (which may be a component) and maybe some other web components. Remove everything from the body except the and the app header. + +Now, add two new framework components from mustang: and . Here is what the new of your index.html should look like: + + + + +
+ + +
+ +
+
+ +All the provides= arguments are up to you, but I suggest replacing “blazing” with a short name of your app everywhere, to avoid confusion. + +Notice the two new components, and . The reason they are arranged this way is that is going to load new content based on the URL. Anything that does not need to change when the URL changes (such as the page header component) should be outside of . The element is going to listen for clicks in order to prevent reloads on link navigation. So the header needs to be inside of . also makes it easier to perform navigation by sending messages, for example when needs to redirect to the login page (or view). + +You can also remove all +Keep all the s in the which load your fonts. Also keep the s for the CSS files tokens.css, reset.css and page.css. + +Create main.ts +All of your pages will be part a single app now, which is loaded from the index.html. This is the main entry point into your app. To start, you at least need to define any custom elements in the index.html, using define. We’ll also need html from lit. And if you have a custom element for your app header, you can define it here, too. + +import { + Auth, + define, + History, + Switch +} from "@calpoly/mustang"; +import { html, LitElement } from "lit"; +import { HeaderElement } from "./components/blazing-header"; + +define({ + "mu-auth": Auth.Provider, + "mu-history": History.Provider, + "blazing-header": HeaderElement +}); +Configure the router, +Unlike the other components we are using from mustang, requires some customization. This customization can be done by extending the Switch.Element class, passing additional arguments through the super constructor. + +To configure the Switch.Element, you need to provide the routes when calling super(). The second argument to super() is the name passed as the provides attribute on in the index.html. The third argument should be the provides attribute on . + +So, in your define({ }), add a definition for mu-switch that looks like this: + +"mu-switch": class AppSwitch extends Switch.Element { + constructor() { + super(routes, "blazing:history", "blazing:auth"); + } +}, + needs access to the auth provider because it implements protected routes, which cannot be rendered unless the user is authenticated. Even though we’re not going to have protected routes, we still need to provide this additional argument to . + +Lets define routes now. This needs to go before the define() statement, where it’s referenced. You should list at least two routes that will bring up different views, and then also a default route for the home view. All of these routes should start with /app/. + +The reason for this is we will again be serving our app from the same Express server as our API. Putting all the “pages” under the same prefix makes sure we don’t have any name collisions. Also, we will want to serve the same HTML for any request that starts with /app/ since we are making a single-page app. + +You should also have a redirect from / to /app. The home page will be served at /app, and we want everyone who goes to the root of the server to get the landing page. + +Here is an example list of routes. You should modify this to work with the routes you want for your app: + +const routes = [ + { + path: "/app/tour/:id", + view: (params: Switch.Params) => html` + + ` + }, + { + path: "/app", + view: () => html` + + ` + }, + { + path: "/", + redirect: "/app" + } +]; +Don’t worry if you don’t have these views defined yet in proto. You can make up names for now and we will get to defining those components later. + +Each route has a path property, which is similar to the paths that we define in Express. Every time the browser’s location changes, will try each route’s path (in the order you list them in routes) until it finds a match. When it finds a match, it will either render a new view or redirect to another route. + +If redirect is given, that path will replace the path in the browser’s URL, and then will go through the list of routes again to match it. + +If view is given, the function is executed and the result is rendered in the Shadow DOM of the . You will recognize the html expressions from Lit, and in fact, Lit is used to render these expressions. Notice how segments of the URL path can be used to pass path parameters into the html expression, where they can be used in any of the ways Lit allows you to pass data to a component. + +Even though this expression is evaluated using Lit, the components you give do not need to be LitElements. Lit is able to render any HTML custom element, so you could re-use existing components you created in proto before we started Lit. + +Convert existing page(s) to components +Look at index.html again. See where the is? You deleted a bunch of HTML that used to be where the is now. Go back to proto and look at what that was. Is it a single component? If, so you are lucky. Otherwise, you will now use Lit to create a component which renders the contents of the page. + +If your home page is very complex, this may seem daunting. It’s ok to implement something much simpler as your home page for now. Eventually, all your pages will be Lit components, but it’s ok to wait until you have a little more experience with Lit before tackling the really beastly ones. + +We’ll start by creating a new file (I called mine home-view.ts) in the directory app/src/views. I recommend you keep views (entire screens) separate from components (like your page header) which tend to be re-used. At this point, my app/src directory looks like this (we’ll talk about main.ts shortly): + +src +├── components +│ └── blazing-header.ts +├── main.ts +├── styles +│ ├── headings.css.ts +│ ├── icon.css.ts +│ └── reset.css.ts +└── views + └── home-view.ts +Start writing home-view.ts by create a class which for the component: + +import { css, html, LitElement } from "lit"; + +export class HomeViewElement extends LitElement { + render() { + return html` + `; + } + + // more to come +} +In general, to convert a static HTML page to a Lit component, you can start by copying the HTML into the render() method. This should load, but probably won’t look great. That’s because these elements are now in the light DOM, so they are not getting the styles they need. Look in your CSS files to see which rules you need to get this looking correct. + +Creating pages that load data +As described above, is essentially static HTML. There is no state and no attributes. In general, your pages will need to get data and have attributes, which will be set in the route. The procedure for creating a page is similar to what we did in Labs 8 and 9, where we built a template and then populated that template with data from a REST API. + +For example, I have a element which takes a src attribute to indicate where to read the JSON from. Let’s change that element now so that it takes a user-id attribute instead, and uses that to compute the API URL: + +export class TravelerViewElement extends LitElement { + static uses = define({ + "mu-form": Form.Element + }); + + @property(attribute: "user-id") + userid?: string; + + @property() + mode = "view"; + + @state() + traveler?: Traveler; + + get src() { + return `/api/travelers/${this.userid}`; + } +That is pretty much all that has to change. Anywhere that we use this.src, we will get the correct value, based on the user-id attribute. + +Go ahead and convert the pages you created in Labs 8 and 9 to views, and add them to the routes. + +Connecting Vite in Development mode to the API server +When we started using Lit and Typescript, we started using Vite in development mode, which was really great because of Hot Module Reload (HMR). + +To use Vite’s development server, we must access our app at localhost:5173, but the API is still being served by Express at localhost:3000. And since we specify our API routes as server-absolute (meaning they start with a /), and when we pass those URLs to fetch, the request goes to the same server from which the HTML was loaded. + +We solved that problem before by using Vite in production mode. Then we could serve proto as static files, using the same Express server. + +So it seems we can’t have it all: either we get Vite’s HMR and can’t use our APIs, or we have to build our frontend each time we make a change, and test the built code. Now that we’re going to be spending most of our time in app, it would be really great if we could get our API served on the same port as Vite. + +We can make requests to the Express server alongside Vite’s development server if we tell Vite to proxy the API requests to the other server. To our app, it will look like the API is available on port 5173, the same port on which Vite is serving our index.html. + +To set up this proxy, create a vite.config.js file in your packages/app and enter this: + +// app/vite.config.js +export default { + server: { + proxy: { + "/api": "http://localhost:3000", + "/auth": "http://localhost:3000", + "/images": "http://localhost:3000", + "/login": "http://localhost:3000", + "/register": "http://localhost:3000" + } + } +}; +Now run npm run dev. If your page makes any api requests, they are now being sent to your Express server. Make sure you also have your server running. You will need to do this in a separate terminal from the one where Vite is running. You don’t want it to serve pages from proto any more, so you can use the basic start script: + +cd packages/server +npm run start +What about the Login page? +You may be tempted to convert your login page to a component, and add it to the routes. That is fine, and you are welcome to do that. + +However, it’s also common to have the login and new user pages not be part of the app. The reason is that simplicity is better for security, and also for reliability. You don’t want somebody to be able to hack their way into your app through your login page. You also don’t want your login page to be unavailable if there’s an error keeping the app from loading or showing the login page. + +So you can also copy your login.html from proto and have it work the same way that it did before. + +Test your SPA +At this point, you should be able to run your SPA and test the same view which you worked on in Labs 8 and 9. If that view component took an attribute, you should be able to specify the value of that attribute in the URL, rather than hard-coding it like we did previously. Try to get those views working correctly before continuing. + +For now, use the Vite development server (npm run dev). You should’ve had it set up to proxy API requests to your Express server. Note that you still need to have the Express server running in another terminal when using the Vite dev server. + +To really see if the router is working, you need to have more than one view component. If you have not done so already, convert another one of your pages from proto into a view. + +Alternatively, if you have a view which does not require any data (such as an “about” page or a splash page), that will be easier to work with for now. For that kind of view, you may not even need to use Lit. + +Once you have two views working, try adding a link from one view to the other, using . Now test the link to make sure you can navigate from one view to the other. Also try using the “Back” button on your browser to return to the first view. + +If you navigate back and forth with the Console or Network tab open, you should see that there is no page reload happening. This is important to check, as it is the key to the preferred user experience of a single-page app. If the Console or Network tab is cleared and then re-written when you click on a link, that means it’s reloading the page. If this happens, see if you can figure out why. (It’s most likely because the is not getting initialized as a custom element.) + +Do not proceed unless you are certain that the page is not reloading. Ask me or our TA to look at it in lab if you are not sure. + +Serving your SPA from server +The Vite development server assumes that your index.html is a single-page app. When you request a page that starts with /app, it’s still going to serve the same index.html file, because there isn’t an app/index.html file available. + +By default, an HTTP server serving static files will not do this. You can try it out by requesting http://localhost:3000/app. It will respond with 404 (Not Found) status. So our new app and client-side router can only be loaded from http://localhost:3000/. Now, that first redirect to /app should not cause a page reload, so you may be able to navigate around just fine, but if you ever tried to use the browser’s refresh button, you would get a 404. This is not acceptable. + +The same would happen if you saved a bookmark and tried to navigate to it later, or even in another tab or window. The ability to load any view within an app is known as deep linking. + +Fortunately, there is any easy way to solve this problem in Express. What we need is an Express route that will match any URL that starts with /app always return our index.html. We can use the STATIC variable which we pass in to our server to find the index.html. Once we find it, we can (asynchronously) read it using the NodeJS fs/promises package, and then send the HTML as a response. + +You’ll need to import both path and fs, so add these to your server/src/index.ts: + +// packages/server/src/index.ts +import fs from "node:fs/promises"; +import path from "path"; +You may already have path imported from an earlier assignment. + +Now, add an app.use statement. I usually put it just before the app.listen. Order is not terribly important here, as long as it’s before the listen. + +// SPA Routes: /app/... +app.use("/app", (req: Request, res: Response) => { + const indexHtml = path.resolve(staticDir, "index.html"); + fs.readFile(indexHtml, { encoding: "utf8" }).then((html) => + res.send(html) + ); +}); +With app.use, we don’t need any wildcards in the path to make it match longer URLs, as we would if we used app.get. + +If you haven’t already done so, you’ll want to have an easy way to switch between running Express serving proto vs app. You should be getting the value of staticDir from an environment variable, which is set from an NPM script. It’s useful to be able to start the server specifying either proto, app, or nothing for the frontend. (When using the Vite dev server, you don’t need Express to serve a frontend at all.) Here is a set of NPM script to do that: + +/* in server/package.json */ + "scripts": { + "dev": "nodemon", + "build": "npx etsc", + "start": "npm run build && npm run start:node", + "start:api": "cross-env STATIC=./public npm run start", + "start:app": "cross-env STATIC=../app/dist npm run start", + "start:node": "node dist/index.js", + "start:proto": "cross-env STATIC=../proto/dist npm run start", + "check": "tsc --noEmit" + }, +I usually use npm run dev only when also running app with npm run dev, so I don’t worry whether nodemon is setting STATIC correctly. + +Time to try it out! + +npm run start:app +And then go to http://localhost:3000 in your browser. You will probably need to log in again because the JWT tokens time out in 24 hours, and they are not shared between the two ports, even on the same machine. (The technical way of saying this is that each origin has its own private localStorage.) \ No newline at end of file diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index bdba3c4..c0342d4 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -5,9 +5,9 @@ "requires": true, "packages": { "node_modules/@calpoly/mustang": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@calpoly/mustang/-/mustang-1.0.15.tgz", - "integrity": "sha512-Wi6vi9ULYKeYhgy79QZZRaQmMhscc5CJ99/Vm8rySgYVPjotb2mkUjACUYqCoqeAokprgnRJI+efSzV1d8ZuYw==", + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/@calpoly/mustang/-/mustang-1.0.18.tgz", + "integrity": "sha512-ObYZQx3BiCNVepCZNvQnyRgxvUC+QHoxwVz0QEKC3ZSKQFWa4SHOUCA0dyggATc7Ns7Dp0TskMG9osTuxI2/lg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -17,293 +17,6 @@ "vite": "^5.4.2" } }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@calpoly/mustang/node_modules/@esbuild/linux-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", @@ -312,7 +25,6 @@ "x64" ], "dev": true, - "ideallyInert": true, "license": "MIT", "optional": true, "os": [ @@ -322,157 +34,49 @@ "node": ">=12" } }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/netbsd-x64": { + "node_modules/@calpoly/mustang/node_modules/esbuild": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, - "ideallyInert": true, + "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@calpoly/mustang/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/@calpoly/mustang/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "node_modules/@calpoly/mustang/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -507,497 +111,47 @@ "optional": true }, "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/@epic-web/invariant": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", - "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/@esbuild/win32-x64": { + "node_modules/@esbuild/linux-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, - "ideallyInert": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">=18" @@ -1068,230 +222,6 @@ "sparse-bitfield": "^3.0.3" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", - "cpu": [ - "arm" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", - "cpu": [ - "arm" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", - "cpu": [ - "arm" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", - "cpu": [ - "loong64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", @@ -1300,7 +230,6 @@ "x64" ], "dev": true, - "ideallyInert": true, "license": "MIT", "optional": true, "os": [ @@ -1315,88 +244,12 @@ "x64" ], "dev": true, - "ideallyInert": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", - "cpu": [ - "arm64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", - "cpu": [ - "ia32" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", - "cpu": [ - "x64" - ], - "dev": true, - "ideallyInert": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -1495,7 +348,6 @@ "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1636,6 +488,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/app": { + "resolved": "packages/app", + "link": true + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2193,7 +1049,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2423,21 +1278,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3347,7 +2187,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4064,7 +2903,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4432,6 +3270,21 @@ "node": ">=8" } }, + "packages/app": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "http-server": "^14.1.1", + "lit": "^3.3.1" + }, + "devDependencies": { + "@calpoly/mustang": "^1.0.18", + "@types/node": "^24.9.1", + "glob": "^11.0.3", + "typescript": "^5.9.3", + "vite": "^6.4.1" + } + }, "packages/proto": { "version": "1.0.0", "license": "ISC", diff --git a/package-lock.json b/package-lock.json index 12257cc..9249a2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,9 +22,9 @@ } }, "node_modules/@calpoly/mustang": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@calpoly/mustang/-/mustang-1.0.15.tgz", - "integrity": "sha512-Wi6vi9ULYKeYhgy79QZZRaQmMhscc5CJ99/Vm8rySgYVPjotb2mkUjACUYqCoqeAokprgnRJI+efSzV1d8ZuYw==", + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/@calpoly/mustang/-/mustang-1.0.18.tgz", + "integrity": "sha512-ObYZQx3BiCNVepCZNvQnyRgxvUC+QHoxwVz0QEKC3ZSKQFWa4SHOUCA0dyggATc7Ns7Dp0TskMG9osTuxI2/lg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1444,7 +1444,6 @@ "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1585,6 +1584,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/app": { + "resolved": "packages/app", + "link": true + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2142,7 +2145,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3296,7 +3298,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4013,7 +4014,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4381,6 +4381,21 @@ "node": ">=8" } }, + "packages/app": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "http-server": "^14.1.1", + "lit": "^3.3.1" + }, + "devDependencies": { + "@calpoly/mustang": "^1.0.18", + "@types/node": "^24.9.1", + "glob": "^11.0.3", + "typescript": "^5.9.3", + "vite": "^6.4.1" + } + }, "packages/proto": { "version": "1.0.0", "license": "ISC", diff --git a/package.json b/package.json index 230192f..71199c7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "type": "module", "main": "index.js", "scripts": { - "dev": "concurrently \"npm run dev --workspace=server\" \"npm run dev --workspace=proto\"", + "dev": "concurrently \"npm run dev --workspace=server\" \"npm run dev --workspace=app\"", "test": "echo \"Error: no test specified\" && exit 1" }, "workspaces": [ @@ -30,4 +30,4 @@ "bcryptjs": "^3.0.3", "jsonwebtoken": "^9.0.2" } -} +} \ No newline at end of file diff --git a/packages/app/index.html b/packages/app/index.html new file mode 100644 index 0000000..0aa2302 --- /dev/null +++ b/packages/app/index.html @@ -0,0 +1,28 @@ + + + + + + National Parks Adventure Guide + + + + + + + + +
+ +
+
+
+
+ + diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 0000000..108c4f7 --- /dev/null +++ b/packages/app/package.json @@ -0,0 +1,25 @@ +{ + "name": "app", + "version": "1.0.0", + "description": "", + "license": "ISC", + "author": "Colter Purcell", + "type": "module", + "main": "index.html", + "scripts": { + "dev": "npx vite", + "build": "npx tsc && npx vite build", + "start": "http-server dist -p 3000" + }, + "dependencies": { + "http-server": "^14.1.1", + "lit": "^3.3.1" + }, + "devDependencies": { + "@calpoly/mustang": "^1.0.18", + "@types/node": "^24.9.1", + "glob": "^11.0.3", + "typescript": "^5.9.3", + "vite": "^6.4.1" + } +} \ No newline at end of file diff --git a/packages/app/public/assets/icons/camping.svg b/packages/app/public/assets/icons/camping.svg new file mode 100644 index 0000000..594228c --- /dev/null +++ b/packages/app/public/assets/icons/camping.svg @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app/public/images/paths/glacier-point-road.svg b/packages/app/public/images/paths/glacier-point-road.svg new file mode 100644 index 0000000..4806029 --- /dev/null +++ b/packages/app/public/images/paths/glacier-point-road.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Glacier + Point Road + \ No newline at end of file diff --git a/packages/app/public/images/paths/grand-loop.svg b/packages/app/public/images/paths/grand-loop.svg new file mode 100644 index 0000000..730f4a5 --- /dev/null +++ b/packages/app/public/images/paths/grand-loop.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Grand Loop + Road + \ No newline at end of file diff --git a/packages/app/public/images/paths/valley-loop.svg b/packages/app/public/images/paths/valley-loop.svg new file mode 100644 index 0000000..eeea475 --- /dev/null +++ b/packages/app/public/images/paths/valley-loop.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Valley Loop + Road + \ No newline at end of file diff --git a/packages/app/public/styles/page.css b/packages/app/public/styles/page.css new file mode 100644 index 0000000..ecfc4c0 --- /dev/null +++ b/packages/app/public/styles/page.css @@ -0,0 +1,410 @@ +@import url('https://fonts.googleapis.com/css2?family=Knewave&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +@import url('/styles/reset.css'); +@import url('/styles/tokens.css'); + +html { + scroll-behavior: smooth; +} + +body { + background-color: var(--color-background-page); + color: var(--color-text); + font-family: var(--font-family-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-base); + margin: 0; +} + +/* Navigation styling */ +nav-element { + display: block; + margin: 0 0 var(--spacing-xl) 0; +} + +/* Typography */ +h1 { + color: var(--color-primary); + font-family: var(--font-family-display); + font-size: 3rem; + font-weight: var(--font-weight-bold); + line-height: var(--line-height-tight); + margin-bottom: var(--spacing-xxl); + margin-top: 0; + text-align: center; + border-bottom: 3px solid var(--color-accent); + padding-bottom: var(--spacing-lg); +} + +h2 { + color: var(--color-primary); + font-family: var(--font-family-display); + font-size: 2rem; + font-weight: var(--font-weight-bold); + line-height: var(--line-height-tight); + margin-top: 0; + margin-bottom: var(--spacing-xl); + padding-left: var(--spacing-md); + border-left: 4px solid var(--color-accent); +} + +article { + background-color: var(--color-background-section); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: var(--spacing-lg); +} + +article>h1, +article>p { + grid-column: 1 / -1; +} + +section { + background-color: var(--color-background-card); + padding: var(--spacing-lg); + margin: var(--spacing-sm) 0; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; + box-sizing: border-box; +} + +/* Card component specific styles */ +card-grid { + display: block; + width: 100%; + box-sizing: border-box; + margin-bottom: var(--spacing-md); +} + +card-element { + display: block; + box-sizing: border-box; +} + +/* Flexbox Layout System for Sparse Content */ +.flex-section { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +.flex-section.flex-row { + flex-direction: row; + flex-wrap: wrap; + align-items: stretch; +} + +.flex-section.flex-center { + align-items: center; +} + +.flex-item { + flex: 1; + min-width: 320px; + max-width: 100%; +} + +.flex-item.flex-shrink { + flex: 0 1 auto; +} + +.flex-item.flex-fixed { + flex: 0 0 auto; +} + +/* Responsive flex adjustments */ +@media (max-width: 768px) { + .flex-section.flex-row { + flex-direction: column; + } + + .flex-item { + min-width: 100%; + } +} + + + +li strong { + color: var(--color-accent); + font-weight: var(--font-weight-semibold); +} + +/* Link styling */ +a { + color: var(--color-link); + text-decoration: underline; + text-decoration-color: var(--color-link); + text-underline-offset: 2px; + font-weight: var(--font-weight-semibold); + transition: all 0.2s ease; +} + +a:hover { + color: var(--color-link-hover); + text-decoration-color: var(--color-link-hover); +} + +a:visited { + color: var(--color-link-visited); +} + +/* Card-style links for camping types */ +section p a, +section article h3 a { + display: block; + background-color: var(--color-background-section); + padding: var(--spacing-lg) var(--spacing-xl); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + margin: var(--spacing-lg) 0; + transition: all 0.2s ease; + text-decoration: none; + color: var(--color-link); + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +section p a:hover, +section article h3 a:hover { + border-color: var(--color-accent); + background-color: var(--color-background-card); + text-decoration: none; + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +section p a strong, +section article h3 a strong { + color: var(--color-primary); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + display: block; + margin-bottom: var(--spacing-sm); +} + +section p a em, +section article h3 a em { + color: var(--color-text-light); + font-style: italic; + font-size: var(--font-size-sm); + display: block; +} + +/* Anchor target highlighting */ +section:target { + background-color: var(--color-background-anchor-highlight); + border-color: var(--color-accent); + animation: anchor-highlight 1.5s ease-out; +} + +@keyframes anchor-highlight { + 0% { + background-color: var(--color-background-anchor-highlight); + } + + 100% { + background-color: var(--color-background-card); + } +} + +/* SVG Icon Classes */ +.icon { + fill: var(--color-icon); + vertical-align: middle; + display: inline-block; + flex-shrink: 0; +} + +.icon-sm { + width: 16px; + height: 16px; +} + +.icon-md { + width: 24px; + height: 16px; +} + +.icon-lg { + width: 32px; + height: 24px; +} + +.icon-xl { + width: 64px; + height: 32px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + body { + padding: var(--spacing-lg); + } +} + +/* Card list styles - applies to all cards with lists */ +[class*="-card"] ul, +section ul { + list-style-type: disc; + margin-left: var(--spacing-lg); + padding-left: var(--spacing-sm); +} + +[class*="-card"] li, +section li { + margin-bottom: var(--spacing-xs); +} + +/* Authentication message styling */ +.auth-message { + margin: var(--spacing-lg) 0; + text-align: left; +} + +.auth-message p { + margin: 0; + color: var(--color-text); +} + +.auth-message .login-link { + color: var(--color-link); + text-decoration: underline; + font-weight: var(--font-weight-semibold); + border: none; + background: none; + padding: 0; + margin: 0; + display: inline; +} + +.auth-message .login-link:hover { + color: var(--color-link-hover); +} + +/* Form styling */ +form { + width: 100%; +} + +.auth-form { + max-width: 400px; + margin: 0 auto; +} + +.auth-form * { + text-align: left; +} + +form label { + display: block; + margin-bottom: var(--spacing-lg); +} + +form label span { + display: block; + margin-bottom: var(--spacing-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text); + text-align: left; +} + +form label input { + width: 100%; + padding: var(--spacing-md); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + font-family: var(--font-family-primary); + background-color: var(--color-background-page); + color: var(--color-text); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + box-sizing: border-box; +} + +form label input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); +} + +form label input::placeholder { + color: var(--color-text-muted); +} + +form button[type="submit"] { + background-color: var(--color-accent); + color: var(--color-text-inverted); + border: none; + padding: var(--spacing-md) var(--spacing-xl); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + font-family: var(--font-family-primary); + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; + margin-top: var(--spacing-lg); +} + +form button[type="submit"]:hover:not(:disabled) { + background-color: var(--color-accent-hover); + transform: translateY(-1px); +} + +form button[type="submit"]:disabled { + background-color: var(--color-border); + cursor: not-allowed; + transform: none; +} + +form .error:not(:empty) { + color: var(--color-error); + border: 1px solid var(--color-error); + background-color: rgba(220, 53, 69, 0.1); + border-radius: var(--radius-md); + padding: var(--spacing-md); + margin-top: var(--spacing-lg); + font-size: var(--font-size-sm); +} + +/* Auth message styling */ +.auth-message { + text-align: center; + padding: var(--spacing-xxl); + background-color: var(--color-background-card); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + margin: var(--spacing-xl) 0; +} + +.auth-message p { + font-size: var(--font-size-lg); + color: var(--color-text); + margin: 0; +} + +.login-link { + color: var(--color-accent); + text-decoration: none; + font-weight: var(--font-weight-semibold); + padding: var(--spacing-sm) var(--spacing-md); + border: 2px solid var(--color-accent); + border-radius: var(--radius-md); + transition: all 0.2s ease; + display: inline-block; + margin-left: var(--spacing-sm); +} + +.login-link:hover { + background-color: var(--color-accent); + color: var(--color-text-inverted); + transform: translateY(-1px); +} \ No newline at end of file diff --git a/packages/app/public/styles/reset.css b/packages/app/public/styles/reset.css new file mode 100644 index 0000000..46a6e82 --- /dev/null +++ b/packages/app/public/styles/reset.css @@ -0,0 +1,62 @@ +* { + margin: 0; + padding: 0; +} + +ul, +ol { + list-style-type: none; +} + +html:focus-within { + scroll-behavior: smooth; +} + +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; +} + +body { + min-height: 100vh; + text-rendering: optimizeSpeed; + line-height: 1.5; +} + +img, +picture, +svg { + max-width: 100%; + height: auto; + display: block; +} + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + font-size: 100%; + line-height: 1.15; + margin: 0; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +[hidden] { + display: none !important; +} \ No newline at end of file diff --git a/packages/app/public/styles/tokens.css b/packages/app/public/styles/tokens.css new file mode 100644 index 0000000..046189c --- /dev/null +++ b/packages/app/public/styles/tokens.css @@ -0,0 +1,175 @@ +:root { + /* Color Palette - High Contrast Nature Theme */ + --color-primary: #0D4F3C; + /* Deep forest green */ + --color-primary-light: #1B6B47; + /* Medium forest green */ + --color-primary-dark: #062D23; + /* Very dark forest green */ + --color-accent: #FF6B35; + /* Bright orange (trail marker) */ + --color-accent-hover: #E55A2B; + /* Darker orange on hover */ + + /* Text Colors */ + --color-text: #1A1A1A; + /* Near black for maximum contrast */ + --color-text-light: #2D2D2D; + /* Dark gray for secondary text */ + --color-text-inverted: #FFFFFF; + /* Pure white for dark backgrounds */ + --color-text-muted: #666666; + /* Medium gray for muted text */ + + /* Background Colors */ + --color-background-page: #FFFFFF; + /* Pure white page background */ + --color-background-header: var(--color-primary); + /* Dark green header */ + --color-background-section: #FFFFFF; + /* White sections */ + --color-background-card: #F8F9FA; + /* Secondary background (used for messages, highlights) */ + --color-background-secondary: #F5F7FA; + /* Light gray cards */ + --color-background-anchor-highlight: var(--color-accent); + + /* Border Colors */ + --color-border: #C0C0C0; + /* Medium gray borders */ + --color-border-accent: var(--color-accent); + /* Orange accent borders */ + --color-border-light: #E8E8E8; + /* Light gray borders */ + + /* Link Colors */ + --color-link: #8B4513; + /* Brown links */ + --color-link-hover: #FF6B35; + /* Orange on hover */ + --color-link-visited: var(--color-primary-light); + /* Lighter green for visited links */ + + /* Status Colors */ + --color-success: #28A745; + /* Standard success green */ + --color-warning: #FFC107; + /* Standard warning yellow */ + --color-error: #DC3545; + /* Standard error red */ + + /* Icon Color */ + --color-icon: var(--color-text); + --color-icon-muted: var(--color-text-muted); + + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-xxl: 48px; + + /* Typography */ + --font-family-display: 'Knewave', cursive; + --font-family-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.375rem; + --font-size-xxl: 1.75rem; + --font-size-xxxl: 2.25rem; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --line-height-tight: 1.2; + --line-height-base: 1.5; + --line-height-loose: 1.7; + + /* Border Radius */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 16px; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15); + --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.20); +} + +body.dark-mode { + /* Dark Mode Color Palette - Nature Theme */ + --color-primary: #2E8B57; + /* Medium sea green for better contrast */ + --color-primary-light: #3CB371; + /* Lighter medium sea green */ + --color-primary-dark: #1E5A3A; + /* Darker green for depth */ + --color-accent: #FF6B35; + /* Bright orange (trail marker) */ + --color-accent-hover: #E55A2B; + /* Darker orange on hover */ + + /* Text Colors */ + --color-text: #F8F9FA; + /* Light gray for maximum contrast on dark backgrounds */ + --color-text-light: #E9ECEF; + /* Muted gray for secondary text */ + --color-text-inverted: #000000; + /* Black for inverted text on light elements */ + --color-text-muted: #6C757D; + /* Muted gray */ + + /* Background Colors */ + --color-background-page: #0F1419; + /* Dark charcoal page background */ + --color-background-header: var(--color-primary); + /* Deep green header */ + --color-background-section: #1A1E23; + /* Dark slate sections */ + --color-background-card: #212529; + /* Secondary background for dark mode (messages, subtle panels) */ + --color-background-secondary: #0f1417; + /* Dark gray cards */ + --color-background-anchor-highlight: var(--color-accent); + + /* Border Colors */ + --color-border: #495057; + /* Medium dark gray borders */ + --color-border-accent: var(--color-accent); + /* Orange accent borders */ + --color-border-light: #343A40; + /* Lighter dark borders */ + + /* Link Colors */ + --color-link: var(--color-accent); + /* Orange links for visibility */ + --color-link-hover: var(--color-accent-hover); + /* Darker orange on hover */ + --color-link-visited: var(--color-primary-light); + /* Lighter green for visited links */ + + /* Status Colors */ + --color-success: #28A745; + /* Standard success green */ + --color-warning: #FFC107; + /* Standard warning yellow */ + --color-error: #DC3545; + /* Standard error red */ + + /* Icon Color */ + --color-icon: var(--color-text); + --color-icon-muted: var(--color-text-muted); + + /* Spacing, Typography, Border Radius, Shadows remain the same */ +} + +@media (prefers-color-scheme: dark) { + body:not(.light-mode) { + /* Apply dark mode by default if system prefers dark, unless explicitly set to light */ + /* Ensure browser uses dark color rendering for form controls, scrollbars, etc. */ + color-scheme: dark; + } +} \ No newline at end of file diff --git a/packages/app/scripts/theme.js b/packages/app/scripts/theme.js new file mode 100644 index 0000000..f2379ee --- /dev/null +++ b/packages/app/scripts/theme.js @@ -0,0 +1,59 @@ +// Theme toggle functionality +function relayEvent(eventType, data) { + const event = new CustomEvent(eventType, { + detail: data, + bubbles: true + }); + document.dispatchEvent(event); +} + +// Apply theme from localStorage on page load +function applyTheme() { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'dark') { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } +} + +// Sync checkbox state across all pages +function syncCheckbox() { + const darkModeToggle = document.getElementById('dark-mode-toggle'); + if (darkModeToggle) { + const savedTheme = localStorage.getItem('theme'); + darkModeToggle.checked = savedTheme === 'dark'; + } +} + +document.addEventListener('DOMContentLoaded', () => { + // Apply saved theme immediately + applyTheme(); + syncCheckbox(); + + const darkModeToggle = document.getElementById('dark-mode-toggle'); + + if (darkModeToggle) { + // Listen for change events on the checkbox + darkModeToggle.addEventListener('change', (event) => { + // Use relayEvent to send a custom event instead of 'change' + relayEvent('themeToggle', { checked: event.target.checked }); + + if (event.target.checked) { + document.body.classList.add('dark-mode'); + localStorage.setItem('theme', 'dark'); + } else { + document.body.classList.remove('dark-mode'); + localStorage.setItem('theme', 'light'); + } + }); + } +}); + +// Listen for storage changes to sync theme across tabs/windows +window.addEventListener('storage', (event) => { + if (event.key === 'theme') { + applyTheme(); + syncCheckbox(); + } +}); \ No newline at end of file diff --git a/packages/app/src/auth/login-form.ts b/packages/app/src/auth/login-form.ts new file mode 100644 index 0000000..06a95af --- /dev/null +++ b/packages/app/src/auth/login-form.ts @@ -0,0 +1,280 @@ +import { html, css, LitElement } from "lit"; +import { property, state, customElement } from "lit/decorators.js"; + +interface LoginFormData { + username?: string; + password?: string; + confirmPassword?: string; +} + +@customElement("login-form") +export class LoginFormElement extends LitElement { + @state() + formData: LoginFormData = {}; + + @property() + api?: string; + + @property() + redirect: string = "/"; + + @state() + error?: string; + + get canSubmit(): boolean { + return Boolean( + this.api && + this.formData.username && + this.formData.password && + (this.api.includes("register") ? this.formData.confirmPassword : true) + ); + } + + override render() { + return html` + + + + +

${this.error}

+ `; + } + + static styles = [ + css` + :host { + display: contents; + } + + label { + display: block; + margin-bottom: var(--spacing-lg); + } + + label span { + display: block; + margin-bottom: var(--spacing-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text); + } + + label input { + width: 100%; + padding: var(--spacing-md); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + font-family: var(--font-family-primary); + background-color: var(--color-background-page); + color: var(--color-text); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + box-sizing: border-box; + } + + label input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); + } + + label input::placeholder { + color: var(--color-text-muted); + } + + button[type="submit"], + button[type="button"] { + background-color: var(--color-accent); + color: var(--color-text-inverted); + border: none; + padding: var(--spacing-md) var(--spacing-xl); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + font-family: var(--font-family-primary); + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; + margin-top: var(--spacing-lg); + } + + ::slotted(button[type="submit"]), + ::slotted(button[type="button"]) { + background-color: var(--color-accent); + color: var(--color-text-inverted); + border: none; + padding: var(--spacing-md) var(--spacing-xl); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + font-family: var(--font-family-primary); + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; + margin-top: var(--spacing-lg); + } + + ::slotted(button[type="submit"]:hover:not(:disabled)), + ::slotted(button[type="button"]:hover:not(:disabled)) { + background-color: var(--color-accent-hover); + transform: translateY(-1px); + } + + ::slotted(button[type="submit"]:disabled), + ::slotted(button[type="button"]:disabled) { + background-color: var(--color-border); + cursor: not-allowed; + transform: none; + } + + button[type="submit"]:hover:not(:disabled), + button[type="button"]:hover:not(:disabled) { + background-color: var(--color-accent-hover); + transform: translateY(-1px); + } + + button[type="submit"]:disabled, + button[type="button"]:disabled { + background-color: var(--color-border); + cursor: not-allowed; + transform: none; + } + + .error:not(:empty) { + color: var(--color-error); + border: 1px solid var(--color-error); + background-color: rgba(220, 53, 69, 0.1); + border-radius: var(--radius-md); + padding: var(--spacing-md); + margin-top: var(--spacing-lg); + font-size: var(--font-size-sm); + } + `, + ]; + + handleChange(event: Event) { + const target = event.target as HTMLInputElement; + const name = target?.name; + const value = target?.value; + const prevData = this.formData; + + switch (name) { + case "username": + this.formData = { ...prevData, username: value }; + break; + case "password": + this.formData = { ...prevData, password: value }; + break; + case "confirmPassword": + this.formData = { ...prevData, confirmPassword: value }; + break; + } + + // Dispatch custom event to notify parent form about changes + const changeEvent = new CustomEvent("change", { + bubbles: true, + composed: true, + }); + this.dispatchEvent(changeEvent); + + // sync disabled state of any slotted action buttons + this.syncButtonDisabled(); + } + + handleButtonClick() { + this.submitForm(); + } + + submitForm() { + if (this.canSubmit) { + console.log("Submitting form with data:", this.formData); + console.log("API endpoint:", this.api); + + fetch(this?.api || "", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(this.formData), + }) + .then((res) => { + console.log("Response status:", res.status); + if (res.status !== 200 && res.status !== 201) { + return res.json().then((err) => { + console.log("Error response:", err); + throw new Error(err.error || "Request failed"); + }); + } + return res.json(); + }) + .then((json: object) => { + console.log("Response JSON:", json); + const { token } = json as { token: string }; + const customEvent = new CustomEvent("auth:message", { + bubbles: true, + composed: true, + detail: ["auth/signin", { token, redirect: this.redirect }], + }); + console.log("dispatching message", customEvent); + console.log("redirect value:", this.redirect); + console.log("event detail:", customEvent.detail); + this.dispatchEvent(customEvent); + }) + .catch((error: Error) => { + console.log("Submit error:", error); + this.error = error.message || error.toString(); + }); + } else { + console.log("Cannot submit: missing required fields"); + } + } + + connectedCallback(): void { + super.connectedCallback(); + // Use event delegation to listen for input changes on slotted content + this.addEventListener("input", this.handleChange.bind(this)); + this.addEventListener("slotchange", this.handleSlotChange.bind(this)); + // Handle slot changes to ensure we listen to dynamically added inputs + this.handleSlotChange(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener("input", this.handleChange.bind(this)); + this.removeEventListener("slotchange", this.handleSlotChange.bind(this)); + } + + handleSlotChange() { + // Find all input elements in the slots and ensure they trigger events + const inputs = this.querySelectorAll("input"); + inputs.forEach((input) => { + input.addEventListener("input", this.handleChange.bind(this)); + }); + + // Wire up any provided slotted button to submit the form + const buttons = this.querySelectorAll('button[slot="button"]'); + buttons.forEach((btn) => { + // Avoid duplicate listeners by resetting first + btn.replaceWith(btn.cloneNode(true)); + }); + const freshButtons = this.querySelectorAll('button[slot="button"]'); + freshButtons.forEach((btn) => { + btn.addEventListener("click", (e) => { + e.preventDefault(); + this.submitForm(); + }); + }); + + this.syncButtonDisabled(); + } + + private syncButtonDisabled() { + const buttons = this.querySelectorAll('button[slot="button"]'); + buttons.forEach((btn) => { + (btn as HTMLButtonElement).disabled = !this.canSubmit; + }); + } +} diff --git a/packages/app/src/components/card.ts b/packages/app/src/components/card.ts new file mode 100644 index 0000000..5ae0cd0 --- /dev/null +++ b/packages/app/src/components/card.ts @@ -0,0 +1,348 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +interface CardData { + title?: string; + description?: string; + href?: string; + image?: string; + imageAlt?: string; + clickable?: boolean; + backgroundColor?: string; + borderColor?: string; + campsite?: { + name?: string; + capacity?: string; + location?: string; + description?: string; + }; +} + +@customElement("card-element") +class CardElement extends LitElement { + @property({ type: String }) + title = ""; + + @property({ type: String }) + description = ""; + + @property({ type: String }) + href = ""; + + @property({ type: String }) + image = ""; + + @property({ attribute: "image-alt" }) + imageAlt = ""; + + @property({ type: Boolean }) + clickable = false; + + @property({ type: String }) + src?: string; + + @state() + data: CardData | null = null; + + connectedCallback() { + super.connectedCallback(); + if (this.src) this.hydrate(this.src); + } + + hydrate(src: string) { + fetch(src) + .then((res) => res.json()) + .then((json: CardData) => { + this.data = json; + }); + } + + static styles = [ + themeTokens, + css` + :host { + display: block; + --card-image-height: 180px; + } + + .card { + position: relative; + background-color: var(--card-bg-color, var(--color-background-card)); + padding: var(--spacing-lg); + border-radius: var(--radius-md); + border: 1px solid var(--card-border-color, var(--color-border)); + box-shadow: var(--shadow-sm); + transition: all 0.2s ease; + height: auto; + display: flex; + flex-direction: column; + } + + .card.featured { + border: 2px solid var(--color-accent); + box-shadow: var(--shadow-lg); + } + + .card.compact { + min-height: auto; + padding: var(--spacing-md); + } + + .card.has-image { + padding-top: calc(var(--card-image-height) + var(--spacing-lg)); + } + + .card.clickable { + cursor: pointer; + } + + .card:hover { + border-color: var(--color-accent); + box-shadow: var(--shadow-md); + transform: translateY(-1px); + } + + .card-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: var(--card-image-height); + object-fit: cover; + border-radius: var(--radius-md) var(--radius-md) 0 0; + z-index: 0; + } + + .card-content { + flex: 1; + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + } + + .card-title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-primary); + margin: 0 0 var(--spacing-sm) 0; + display: flex; + align-items: center; + gap: var(--spacing-sm); + } + + .card-description { + color: var(--color-text-light); + margin: 0 0 var(--spacing-md) 0; + flex: 1; + } + + .card-description a { + color: var(--color-link); + text-decoration: underline; + text-decoration-color: var(--color-link); + text-underline-offset: 2px; + font-weight: var(--font-weight-semibold); + transition: all 0.2s ease; + } + + .card-description a:hover { + color: var(--color-link-hover); + text-decoration-color: var(--color-link-hover); + } + + .card-description a:visited { + color: var(--color-link-visited); + } + + .card-link { + color: var(--color-link); + text-decoration: none; + font-weight: var(--font-weight-semibold); + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + margin-top: auto; + } + + .card-link:hover { + color: var(--color-link-hover); + text-decoration: underline; + } + + .card-link:focus { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + + .card-slot { + margin-top: var(--spacing-md); + } + + /* When the entire card is clickable */ + .card.clickable .card-link { + pointer-events: none; + } + + .card.clickable::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + } + `, + iconStyles, + ...pageStyles, + ]; + + private _handleCardClick() { + if (this.clickable && this.href) { + window.location.href = this.href; + } + } + + render() { + const currentData = this.data || { + title: this.title, + description: this.description, + href: this.href, + image: this.image, + imageAlt: this.imageAlt, + clickable: this.clickable, + }; + const { title, description, href, image, imageAlt, clickable } = + currentData; + + const cardClasses = [ + "card", + image ? "has-image" : "", + clickable && href ? "clickable" : "", + ] + .filter(Boolean) + .join(" "); + + return html` +
+ `; + } +} + +@customElement("card-grid") +class CardGrid extends LitElement { + @property({ type: String }) + columns = "auto-fit"; + + @property({ attribute: "min-width" }) + minWidth = "280px"; + + @property({ type: String }) + gap = "var(--spacing-lg)"; + + @property({ attribute: "max-columns" }) + maxColumns = ""; + + @property({ type: String }) + alignment = "start"; + + static styles = [ + css` + :host { + display: block; + width: 100%; + box-sizing: border-box; + margin-bottom: var(--spacing-lg); + } + + .grid { + display: grid; + gap: var(--spacing-lg); + width: 100%; + box-sizing: border-box; + align-items: start; + } + `, + ]; + + updated() { + const grid = this.shadowRoot?.querySelector(".grid") as HTMLElement; + if (grid) { + let columns; + if (this.columns === "auto-fit") { + const maxCols = this.maxColumns ? `, ${this.maxColumns}` : ""; + columns = `repeat(auto-fit${maxCols}, minmax(${this.minWidth}, 1fr))`; + } else { + columns = this.columns; + } + + grid.style.gridTemplateColumns = columns; + grid.style.gap = this.gap; + grid.style.alignItems = this.alignment; + grid.style.justifyItems = "stretch"; // Ensure cards stretch to fill available space + } + } + + render() { + return html` +
+ + + +
+ `; + } +} + +export { CardElement, CardGrid }; diff --git a/packages/app/src/components/filtered-select.ts b/packages/app/src/components/filtered-select.ts new file mode 100644 index 0000000..2d77575 --- /dev/null +++ b/packages/app/src/components/filtered-select.ts @@ -0,0 +1,67 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +interface OptionItem { + id: string; + name: string; +} + +@customElement("filtered-select") +export class FilteredSelect extends LitElement { + static styles = css` + label { + display: grid; + gap: var(--spacing-sm); + } + select { + padding: var(--spacing-md); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + width: 100%; + } + `; + + @property({ type: String }) label = "Selection"; + @property({ type: String }) type: "path" | "poi" | "camp" | "" = ""; + @property({ type: Array }) paths: OptionItem[] = []; + @property({ type: Array }) pois: OptionItem[] = []; + @property({ type: Array }) campsites: OptionItem[] = []; + @property({ type: String, attribute: "name" }) name = "activityRef"; + + private onChange(e: Event) { + const select = e.target as HTMLSelectElement; + const value = select.value; + this.dispatchEvent( + new CustomEvent("filtered-select:change", { + detail: { type: this.type, value }, + bubbles: true, + composed: true, + }) + ); + } + + render() { + const options: OptionItem[] = + this.type === "path" + ? this.paths + : this.type === "poi" + ? this.pois + : this.type === "camp" + ? this.campsites + : []; + + return html` + + `; + } +} + +export default FilteredSelect; diff --git a/packages/app/src/components/form-section.ts b/packages/app/src/components/form-section.ts new file mode 100644 index 0000000..88b1814 --- /dev/null +++ b/packages/app/src/components/form-section.ts @@ -0,0 +1,79 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("form-section") +export class FormSection extends LitElement { + @property({ type: String }) maxWidth: string = "450px"; + @property({ type: String }) gap: string = "var(--spacing-lg)"; + @property({ type: String }) minColumnWidth: string = "320px"; + + static styles = [ + css` + :host { + display: block; + width: 100%; + box-sizing: border-box; + /* space between stacked sections */ + margin: var(--spacing-lg); + } + .wrap { + width: 100%; + max-width: var(--form-section-max-width, 1040px); + margin: 0 auto; + padding: var(--spacing-lg); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-elevated); + box-shadow: var(--shadow-md); + display: grid; + gap: var(--form-section-gap, var(--spacing-md)); + box-sizing: border-box; + /* allow the section to grow as large as needed */ + overflow: visible; + } + .title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + } + .content { + display: grid; + /* horizontal spacing via column gap and responsive columns */ + gap: var(--spacing-xl); + grid-template-columns: repeat( + auto-fit, + minmax(var(--form-section-min-col, 320px), 1fr) + ); + /* let content grow naturally without clipping */ + min-width: auto; + } + ::slotted(*) { + max-width: 100%; + box-sizing: border-box; + } + `, + ]; + + render() { + // apply props to CSS variables; if maxWidth is 'none', remove the cap + const mw = (this.maxWidth || "1040px").trim(); + if (mw.toLowerCase() === "none") { + this.style.setProperty("--form-section-max-width", "none"); + } else { + this.style.setProperty("--form-section-max-width", mw); + } + this.style.setProperty("--form-section-gap", this.gap); + this.style.setProperty("--form-section-min-col", this.minColumnWidth); + return html` +
+
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "form-section": FormSection; + } +} diff --git a/packages/app/src/components/nav.ts b/packages/app/src/components/nav.ts new file mode 100644 index 0000000..bce93f0 --- /dev/null +++ b/packages/app/src/components/nav.ts @@ -0,0 +1,297 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; + +@customElement("breadcrumb-link") +class BreadcrumbLink extends LitElement { + @property({ type: String }) + href = ""; + + @property({ type: String }) + text = ""; + + @property({ attribute: "separator-text" }) + separatorText = "→"; + + @property({ type: Boolean, attribute: "hide-separator" }) + hideSeparator = false; + + static styles = [ + css` + :host { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 0.5rem); + } + + a { + color: var(--color-text-inverted, #ffffff); + text-decoration: none; + font-weight: var(--font-weight-bold, 700); + } + + a:hover { + text-decoration: underline; + } + + a:focus { + outline: 2px solid var(--color-accent, #ff6b35); + outline-offset: 2px; + } + + .separator { + color: var(--color-text-inverted, #ffffff); + opacity: 0.7; + } + `, + ]; + + render() { + return html` + + ${this.text} + + ${!this.hideSeparator + ? html` + + ${this.separatorText} + + ` + : ""} + `; + } +} + +@customElement("nav-element") +class NavElement extends LitElement { + @property({ type: Boolean }) + darkMode = false; + + @property({ attribute: "background-color" }) + backgroundColor = ""; + + @property({ type: Boolean, attribute: "hide-theme-toggle" }) + hideThemeToggle = false; + + @property({ attribute: "theme-label" }) + themeLabel = "Dark mode"; + + @state() + loggedIn = false; + + @state() + userid?: string; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + static styles = [ + css` + :host { + display: block; + margin: 0 0 var(--spacing-xl, 1.5rem) 0; + } + + nav { + background-color: var(--color-primary, #0d4f3c); + color: var(--color-text-inverted, #ffffff); + padding: var(--spacing-lg, 1rem) var(--spacing-xl, 1.5rem); + margin: 0; + border-radius: 0 0 var(--radius-lg, 8px) var(--radius-lg, 8px); + font-family: var(--font-family-primary, system-ui, sans-serif); + font-weight: var(--font-weight-semibold, 600); + font-size: var(--font-size-lg, 1.125rem); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-md, 0.75rem); + } + + label { + display: flex; + align-items: center; + gap: var(--spacing-sm, 0.5rem); + cursor: pointer; + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-inverted, #ffffff); + white-space: nowrap; + } + + input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--color-accent, #ff6b35); + cursor: pointer; + } + + ::slotted(a) { + color: var(--color-text-inverted, #ffffff) !important; + text-decoration: none; + font-weight: var(--font-weight-bold, 700); + } + + ::slotted(a:hover) { + text-decoration: underline !important; + } + + ::slotted(a:focus) { + outline: 2px solid var(--color-accent, #ff6b35); + outline-offset: 2px; + } + + .breadcrumbs { + display: flex; + align-items: center; + gap: var(--spacing-sm, 0.5rem); + flex-wrap: wrap; + } + + .nav-actions { + display: flex; + align-items: center; + gap: var(--spacing-md, 0.75rem); + } + + .nav-actions span { + color: var(--color-text-inverted, #ffffff); + font-weight: var(--font-weight-semibold, 600); + white-space: nowrap; + } + + .nav-actions button { + background-color: var(--color-accent, #ff6b35); + color: var(--color-text-inverted, #ffffff); + border: 2px solid var(--color-accent, #ff6b35); + padding: var(--spacing-xs, 0.25rem) var(--spacing-sm, 0.5rem); + border-radius: var(--radius-sm, 4px); + font-family: inherit; + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + } + + .nav-actions button:hover { + background-color: transparent; + color: var(--color-accent, #ff6b35); + transform: translateY(-1px); + } + + .nav-actions button:focus { + outline: 2px solid var(--color-accent, #ff6b35); + outline-offset: 2px; + } + + .nav-actions a { + color: var(--color-text-inverted, #ffffff); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + padding: var(--spacing-xs, 0.25rem) var(--spacing-sm, 0.5rem); + border: 2px solid var(--color-accent, #ff6b35); + border-radius: var(--radius-sm, 4px); + background-color: transparent; + transition: all 0.2s ease; + white-space: nowrap; + } + + .nav-actions a:hover { + background-color: var(--color-accent, #ff6b35); + transform: translateY(-1px); + } + + .nav-actions a:focus { + outline: 2px solid var(--color-accent, #ff6b35); + outline-offset: 2px; + } + `, + ]; + + connectedCallback() { + super.connectedCallback(); + // Apply saved theme from localStorage + const savedTheme = localStorage.getItem("theme"); + this.darkMode = savedTheme === "dark"; + document.body.classList.toggle("dark-mode", this.darkMode); + + // Set up auth observer + this._authObserver.observe((auth) => { + const { user } = auth; + if (user && user.authenticated) { + this.loggedIn = true; + this.userid = user.username; + } else { + this.loggedIn = false; + this.userid = undefined; + } + }); + } + + updated() { + if (this.backgroundColor) { + this.style.setProperty("--nav-bg-color", this.backgroundColor); + } + } + + render() { + return html` + + `; + } + + private _handleThemeToggle(event: Event) { + const target = event.target as HTMLInputElement; + this.darkMode = target.checked; + + // Update body class and localStorage + document.body.classList.toggle("dark-mode", this.darkMode); + localStorage.setItem("theme", this.darkMode ? "dark" : "light"); + + const customEvent = new CustomEvent("themeToggle", { + detail: { checked: this.darkMode }, + bubbles: true, + }); + this.dispatchEvent(customEvent); + } + + private _handleSignOut(event: Event) { + event.preventDefault(); + const customEvent = new CustomEvent("auth:message", { + bubbles: true, + composed: true, + detail: ["auth/signout"], + }); + this.dispatchEvent(customEvent); + } +} + +export { BreadcrumbLink, NavElement }; +export default NavElement; diff --git a/packages/app/src/components/parks-listing.ts b/packages/app/src/components/parks-listing.ts new file mode 100644 index 0000000..c2acf08 --- /dev/null +++ b/packages/app/src/components/parks-listing.ts @@ -0,0 +1,176 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; + +interface Park { + parkid: string; + name: string; + description?: string; + card?: { + title?: string; + description?: string; + image?: string; + imageAlt?: string; + }; +} + +@customElement("parks-listing") +class ParksListing extends LitElement { + @state() + parks: Park[] = []; + + @state() + loading = true; + + @state() + error: string | null = null; + + @state() + user: any = null; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + static styles = [ + css` + :host { + display: block; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg, 1rem); + margin: var(--spacing-md, 0.75rem) 0; + } + + .auth-message { + background-color: var(--color-background-secondary, #f5f5f5); + border-radius: var(--radius-md, 4px); + } + + .login-link { + color: var(--color-link, #0066cc); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + } + + .login-link:hover { + text-decoration: underline; + } + `, + ]; + + connectedCallback() { + super.connectedCallback(); + + // Set up auth observer + this._authObserver.observe((auth) => { + console.log("🔑 Parks listing auth observer fired:", auth); + const { user } = auth; + + if (user && user.authenticated) { + console.log("✅ User authenticated in parks listing, loading parks"); + this.user = user; + this.loadParks(); + } else { + console.log("❌ User not authenticated in parks listing"); + this.user = null; + this.parks = []; + this.loading = false; + this.error = null; + } + }); + } + + async loadParks() { + try { + console.log("🏞️ Parks listing loadParks called"); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + console.log("🔑 Auth headers from parks listing:", headers); + + const response = await fetch("/api/parks", { headers }); + console.log("📨 Response status:", response.status); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.parks = await response.json(); + console.log( + "🎯 Parks loaded in parks listing:", + this.parks.length, + "parks" + ); + this.loading = false; + } catch (error) { + console.log("❌ Error loading parks in parks listing:", error); + this.error = + error instanceof Error ? error.message : "Failed to load parks"; + this.loading = false; + } + } + + render() { + return html`
${this.renderParksContent()}
`; + } + + renderParksContent() { + if (!this.user?.authenticated) { + return html` +
+

+ Please to view + parks. +

+
+ `; + } + + if (this.loading) { + return html` +
+

Loading parks...

+
+ `; + } + + if (this.error) { + return html` +
+

Error loading parks: ${this.error}

+
+ `; + } + + if (this.parks.length === 0) { + return html` +
+

No parks available.

+
+ `; + } + + return html` + + ${this.parks.map( + (park) => html` + + ` + )} + + `; + } +} + +export default ParksListing; diff --git a/packages/app/src/components/paths-listing.ts b/packages/app/src/components/paths-listing.ts new file mode 100644 index 0000000..3aaed68 --- /dev/null +++ b/packages/app/src/components/paths-listing.ts @@ -0,0 +1,176 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; + +interface Path { + pathid: string; + name: string; + description?: string; + type?: "road" | "trail"; + park?: string; + parkName?: string; + image?: string; + imageAlt?: string; + card?: { + title?: string; + description?: string; + href?: string; + image?: string; + imageAlt?: string; + }; +} + +@customElement("paths-listing") +class PathsListing extends LitElement { + @state() + paths: Path[] = []; + + @state() + loading = true; + + @state() + error: string | null = null; + + @state() + user: any = null; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + static styles = [ + css` + :host { + display: block; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg, 1rem); + margin: var(--spacing-md, 0.75rem) 0; + } + + .auth-message { + background-color: var(--color-background-secondary, #f5f5f5); + border-radius: var(--radius-md, 4px); + } + + .login-link { + color: var(--color-link, #0066cc); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + } + + .login-link:hover { + text-decoration: underline; + } + `, + ]; + + connectedCallback() { + super.connectedCallback(); + + // Set up auth observer + this._authObserver.observe((auth) => { + const { user } = auth; + + if (user && user.authenticated) { + this.user = user; + this.loadPaths(); + } else { + this.user = null; + this.paths = []; + this.loading = false; + this.error = null; + } + }); + } + + async loadPaths() { + try { + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + const response = await fetch("/api/paths", { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.paths = await response.json(); + this.loading = false; + } catch (error) { + this.error = + error instanceof Error ? error.message : "Failed to load paths"; + this.loading = false; + } + } + + render() { + return html`
${this.renderContent()}
`; + } + + renderContent() { + if (!this.user?.authenticated) { + return html` +
+

+ Please to view + paths. +

+
+ `; + } + + if (this.loading) { + return html` +
+

Loading paths...

+
+ `; + } + + if (this.error) { + return html` +
+

Error loading paths: ${this.error}

+
+ `; + } + + if (this.paths.length === 0) { + return html` +
+

No paths available.

+
+ `; + } + + return html` + + ${this.paths.map((path) => { + const title = path.card?.title || path.name; + const description = path.card?.description || path.description || ""; + // Always use SPA route to avoid legacy .html links from seed data + const href = `/app/paths/${path.pathid}`; + const image = path.card?.image || path.image || ""; + const imageAlt = path.card?.imageAlt || path.imageAlt || path.name; + + return html` + + `; + })} + + `; + } +} + +export default PathsListing; diff --git a/packages/app/src/components/poi-listing.ts b/packages/app/src/components/poi-listing.ts new file mode 100644 index 0000000..96c7da2 --- /dev/null +++ b/packages/app/src/components/poi-listing.ts @@ -0,0 +1,176 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; + +interface POI { + poiid: string; + name: string; + description?: string; + park?: string; + parkName?: string; + type?: string; + card?: { + title?: string; + description?: string; + href?: string; + image?: string; + imageAlt?: string; + }; +} + +@customElement("poi-listing") +class PoiListing extends LitElement { + @state() + pois: POI[] = []; + + @state() + loading = true; + + @state() + error: string | null = null; + + @state() + user: any = null; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + static styles = [ + css` + :host { + display: block; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg, 1rem); + margin: var(--spacing-md, 0.75rem) 0; + } + + .auth-message { + background-color: var(--color-background-secondary, #f5f5f5); + border-radius: var(--radius-md, 4px); + } + + .login-link { + color: var(--color-link, #0066cc); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + } + + .login-link:hover { + text-decoration: underline; + } + `, + ]; + + connectedCallback() { + super.connectedCallback(); + + // Set up auth observer + this._authObserver.observe((auth) => { + const { user } = auth; + + if (user && user.authenticated) { + this.user = user; + this.loadPOIs(); + } else { + this.user = null; + this.pois = []; + this.loading = false; + this.error = null; + } + }); + } + + async loadPOIs() { + try { + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + const response = await fetch("/api/poi", { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.pois = await response.json(); + this.loading = false; + } catch (error) { + this.error = + error instanceof Error ? error.message : "Failed to load POIs"; + this.loading = false; + } + } + + render() { + return html`
${this.renderContent()}
`; + } + + renderContent() { + if (!this.user?.authenticated) { + return html` +
+

+ Please to view + points of interest. +

+
+ `; + } + + if (this.loading) { + return html` +
+

Loading points of interest...

+
+ `; + } + + if (this.error) { + return html` +
+

Error loading POIs: ${this.error}

+
+ `; + } + + if (this.pois.length === 0) { + return html` +
+

No points of interest available.

+
+ `; + } + + return html` + + ${this.pois.map((poi) => { + const title = poi.card?.title || poi.name; + const description = poi.card?.description || poi.description || ""; + // Prefer hierarchical SPA route when park id is available + const href = poi.park + ? `/app/parks/${poi.park}/poi/${poi.poiid}` + : `/app/poi/${poi.poiid}`; + const image = poi.card?.image || ""; + const imageAlt = poi.card?.imageAlt || poi.name; + + return html` + + `; + })} + + `; + } +} + +export default PoiListing; diff --git a/packages/app/src/components/section-header.ts b/packages/app/src/components/section-header.ts new file mode 100644 index 0000000..ea28813 --- /dev/null +++ b/packages/app/src/components/section-header.ts @@ -0,0 +1,143 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +interface SectionHeaderData { + title: string; + icon?: string; + iconSize?: string; +} + +interface SectionHeadersCollection { + headers: { [key: string]: SectionHeaderData }; +} + +@customElement("section-header") +class SectionHeader extends LitElement { + @property({ type: String }) + src?: string; + + @property({ type: String }) + title = ""; + + @property({ type: String }) + icon = ""; + + @property({ type: String }) + iconSize = "lg"; + + @state() + data: SectionHeaderData | null = null; + + connectedCallback() { + super.connectedCallback(); + if (this.src) this.hydrate(this.src); + } + + hydrate(src: string) { + // Handle JSON fragment references like /data/section-headers.json#yosemite-poi + const [url, fragment] = src.split("#"); + fetch(url) + .then((res) => res.json()) + .then((json: SectionHeaderData | SectionHeadersCollection) => { + if (fragment && "headers" in json) { + // It's a collection, get the specific header + this.data = json.headers[fragment]; + } else { + // It's a direct header object + this.data = json as SectionHeaderData; + } + }); + } + + static styles = [ + css` + :host { + display: block; + } + + h2 { + color: var(--color-primary); + font-family: var(--font-family-display); + font-size: 2rem; + font-weight: var(--font-weight-bold); + line-height: var(--line-height-tight); + margin-top: 0; + margin-bottom: var(--spacing-xl); + padding-left: var(--spacing-md); + border-left: 4px solid var(--color-accent); + display: flex; + align-items: center; + gap: var(--spacing-sm); + } + + .icon { + fill: var(--color-icon); + vertical-align: middle; + display: inline-block; + } + + .icon-sm { + width: 16px; + height: 16px; + } + + .icon-md { + width: 24px; + height: 24px; + } + + .icon-lg { + width: 32px; + height: 32px; + } + + .icon-xl { + width: 64px; + height: 64px; + } + + /* Allow custom content in addition to or instead of title */ + .title-content { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex: 1; + } + `, + ]; + + render() { + const currentData = this.data || { + title: this.title, + icon: this.icon, + iconSize: this.iconSize, + }; + const { title, icon, iconSize } = currentData; + + return html` +

+ + ${icon + ? html` + + + + ` + : ""} + + +
+ ${title} + +
+ + +

+ `; + } +} + +export { SectionHeader }; diff --git a/packages/app/src/main.ts b/packages/app/src/main.ts new file mode 100644 index 0000000..7148ddb --- /dev/null +++ b/packages/app/src/main.ts @@ -0,0 +1,122 @@ +import { Auth, define, History, Switch, Form, Store } from "@calpoly/mustang"; +import update from "./update"; +import { html } from "lit"; +import { NavElement } from "./components/nav.ts"; +import { CardElement, CardGrid } from "./components/card.ts"; +import { SectionHeader } from "./components/section-header.ts"; +import ParksListingElement from "./components/parks-listing.ts"; +import PathsListingElement from "./components/paths-listing.ts"; +import PoiListingElement from "./components/poi-listing.ts"; +import "./views/home-view.ts"; +import "./views/parks-view.ts"; +import "./views/paths-view.ts"; +import "./views/trips-view.ts"; +import "./views/poi-view.ts"; +import "./views/itinerary-view.ts"; +import "./views/itinerary-create-view.ts"; +import "./pages/park-page.ts"; +import "./pages/campsite-page.ts"; +import "./pages/path-page.ts"; +import "./pages/poi-page.ts"; +import "./views/trip-view.ts"; +import "./views/login-view.ts"; +import "./views/register-view.ts"; +import { Msg } from "./messages.ts"; +import { init, Model } from "./model.ts"; +// campers view removed (placeholder content) - routes/imports cleaned up + +const routes = [ + { + path: "/app/parks", + view: () => html``, + }, + { + path: "/app/parks/:parkid", + view: (params: Switch.Params) => + html``, + }, + { + path: "/app/paths", + view: () => html``, + }, + { + path: "/app/paths/:pathid", + view: (params: Switch.Params) => + html``, + }, + { + path: "/app/trips", + view: () => html``, + }, + { + path: "/app/trips/itinerary", + view: () => html``, + }, + { + path: "/app/trips/new", + view: () => html``, + }, + { + path: "/app/trips/:slug", + view: (params: Switch.Params) => + html``, + }, + { + path: "/app/poi", + view: () => html``, + }, + { + path: "/app/parks/:parkid/poi/:poiid", + view: (params: Switch.Params) => + html``, + }, + { + path: "/app/login", + view: () => html``, + }, + { + path: "/app/register", + view: () => html``, + }, + { + path: "/app/poi/:poiid", + view: (params: Switch.Params) => + html``, + }, + { + path: "/app/campsites/:siteid", + view: (params: Switch.Params) => + html``, + }, + { + path: "/app", + view: () => html``, + }, + { + path: "/", + redirect: "/app", + }, +]; + +define({ + "mu-auth": Auth.Provider, + "mu-history": History.Provider, + "mu-switch": class AppSwitch extends Switch.Element { + constructor() { + super(routes, "natty:history", "natty:auth"); + } + }, + "nav-element": NavElement, + "card-element": CardElement, + "card-grid": CardGrid, + "section-header": SectionHeader, + "parks-listing": ParksListingElement, + "paths-listing": PathsListingElement, + "poi-listing": PoiListingElement, + "mu-form": Form.Element, + "mu-store": class AppStore extends Store.Provider { + constructor() { + super(update, init, "natty:auth"); + } + }, +}); diff --git a/packages/app/src/messages.ts b/packages/app/src/messages.ts new file mode 100644 index 0000000..f180aa4 --- /dev/null +++ b/packages/app/src/messages.ts @@ -0,0 +1,27 @@ +type Callbacks = { + onSuccess?: () => void; + onFailure?: (err: Error) => void; +}; + +export type Msg = + | [ + "itinerary/create", + { + itinerary: any | any[]; + callbacks?: Callbacks; + } + ] + | [ + "itinerary/update", + { + itinerary: any; + callbacks?: Callbacks; + } + ] + | [ + "itinerary/delete", + { + itineraryid: string; + callbacks?: Callbacks; + } + ]; diff --git a/packages/app/src/model.ts b/packages/app/src/model.ts new file mode 100644 index 0000000..15b3177 --- /dev/null +++ b/packages/app/src/model.ts @@ -0,0 +1,49 @@ +// Keep the app model focused on itinerary flows for MVU. +// Avoid importing server types here to keep this module decoupled +// from runtime aliasing and to allow lightweight typing. + +export interface ItineraryDayActivity { + time: string; + activity: string; + location: string; + description?: string; + // Optional references to domain entities + pathId?: string; + poiId?: string; + campsiteId?: string; +} + +export interface ItineraryDayPlan { + itineraryid: string; // e.g. `${tripid}-day${n}` + tripid: string; + tripName: string; + day: number; + date: string; // YYYY-MM-DD + notes?: string; + campsiteId?: string; + campsiteName?: string; + activities: ItineraryDayActivity[]; + card?: { + title: string; + description?: string; + href?: string; + }; +} + +export type ItineraryAction = "create" | "update" | "delete" | "load"; + +export interface ItineraryStatus { + status?: "idle" | "pending" | "loaded" | "error"; + action?: ItineraryAction; + error?: string; +} + +export interface Model { + itineraries?: ItineraryDayPlan[]; // recent or active client-side itineraries + itineraryStatus?: ItineraryStatus; +} + +export const init: Model = { + itineraries: [], + itineraryStatus: { status: "idle" } +}; diff --git a/packages/app/src/pages/campsite-info.ts b/packages/app/src/pages/campsite-info.ts new file mode 100644 index 0000000..57385bb --- /dev/null +++ b/packages/app/src/pages/campsite-info.ts @@ -0,0 +1,152 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +interface CampsiteInfoData { + siteid?: string; + name?: string; + capacity?: string; + location?: string; + description?: string; + maxOccupancy?: string; + backgroundColor?: string; + + card?: { + title?: string; + description?: string; + href?: string; + }; +} + +@customElement("campsite-info") +class CampsiteInfo extends LitElement { + @property({ type: String }) + name = ""; + + @property({ type: String }) + capacity = ""; + + @property({ type: String }) + location = ""; + + @property({ type: String }) + description = ""; + + @property({ type: String }) + src?: string; + + @state() + data: CampsiteInfoData | null = null; + + connectedCallback() { + super.connectedCallback(); + if (this.src) this.hydrate(this.src); + } + + hydrate(src: string) { + fetch(src) + .then((res) => res.json()) + .then((json: CampsiteInfoData) => { + this.data = json; + }); + } + + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + margin-bottom: var(--spacing-xl); + } + + .campsite-header { + background-color: var( + --campsite-bg-color, + var(--color-background-card) + ); + padding: var(--spacing-lg); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + } + + .campsite-header.featured { + border: 2px solid var(--color-accent); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } + + .campsite-header.compact { + padding: var(--spacing-md); + } + + h1 { + color: var(--color-primary); + font-family: var(--font-family-display); + font-size: 2rem; + font-weight: var(--font-weight-bold); + margin: 0 0 var(--spacing-sm) 0; + } + + .meta { + display: flex; + gap: var(--spacing-md); + flex-wrap: wrap; + margin-bottom: var(--spacing-md); + } + + .meta span { + background-color: var(--color-accent); + color: white; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: var(--font-weight-medium); + } + + p { + color: var(--color-text-light); + margin: 0; + line-height: var(--line-height-relaxed); + } + `, + ]; + + render() { + const currentData = this.data || { + name: this.name, + capacity: this.capacity, + location: this.location, + description: this.description, + }; + const { name, capacity, location, description } = currentData; + const headerClass = `campsite-header`; + + return html` +
+

+ ${name} +

+ +
+ + ${capacity ? html`${capacity}` : ""} + + + ${location ? html`${location}` : ""} + + +
+ + + ${description ? html`

${description}

` : ""} +
+ + + +
+ `; + } +} + +export { CampsiteInfo }; diff --git a/packages/app/src/pages/campsite-page.ts b/packages/app/src/pages/campsite-page.ts new file mode 100644 index 0000000..88a3171 --- /dev/null +++ b/packages/app/src/pages/campsite-page.ts @@ -0,0 +1,397 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state, property } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +interface ConnectedPath { + pathId: string; + pathName: string; + pathType: "road" | "trail"; +} + +interface NearbyPoi { + poiId: string; + poiName: string; +} + +interface Campsite { + siteid: string; + name: string; + description?: string; + location?: string; + capacity?: number; + card?: { + image?: string; + imageAlt?: string; + }; + connectedPaths?: ConnectedPath[]; + nearbyPoi?: NearbyPoi[]; +} + +@customElement("campsite-page") +class CampsitePage extends LitElement { + @property({ attribute: "site-id" }) + siteIdAttr?: string; + @state() + private _campsite: Campsite | null = null; + + @state() + private _loading = true; + + @state() + private _error: string | null = null; + + @state() + private _user: any = null; + + siteid: string = ""; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + // Getters for properties + get campsite() { + return this._campsite; + } + set campsite(value: Campsite | null) { + this._campsite = value; + } + + get loading() { + return this._loading; + } + set loading(value: boolean) { + this._loading = value; + } + + get error() { + return this._error; + } + set error(value: string | null) { + this._error = value; + } + + get user() { + return this._user; + } + set user(value: any) { + this._user = value; + } + + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg, 1rem); + margin: var(--spacing-md, 0.75rem) 0; + } + + .auth-message { + background-color: var(--color-background-secondary, #f5f5f5); + border-radius: var(--radius-md, 4px); + } + + .login-link { + color: var(--color-link, #0066cc); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + } + + .login-link:hover { + text-decoration: underline; + } + + .panel { + margin-top: var(--spacing-lg); + padding: var(--spacing-lg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-background-card); + } + + .lead { + color: var(--color-text-light); + margin-bottom: var(--spacing-md); + } + + .meta-row, + .chip-row { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; + } + + .chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: 9999px; + font-size: 0.875rem; + color: var(--color-text); + background: var(--color-background); + text-decoration: none; + } + + .chip:hover { + background: var(--color-background-hover); + } + + .icon-sm { + width: 16px; + height: 16px; + fill: currentColor; + } + + .muted { + color: var(--color-text-light); + } + + .campsite-image { + margin-bottom: var(--spacing-lg); + border-radius: var(--radius-md); + overflow: hidden; + } + + .campsite-image img { + width: 100%; + height: auto; + display: block; + border-radius: var(--radius-md); + } + `, + iconStyles, + ]; + + connectedCallback() { + super.connectedCallback(); + + // Require attribute passed by router (no legacy URL parsing) + if (!this.siteIdAttr) { + console.error(" requires site-id attribute"); + this.loading = false; + this.error = "Missing site-id"; + return; + } + this.siteid = this.siteIdAttr; + + // Set up auth observer + this._authObserver.observe((auth) => { + console.log("🔑 Campsite page auth observer fired:", auth); + const { user } = auth; + + if (user && user.authenticated) { + console.log("✅ User authenticated in campsite page, loading data"); + this.user = user; + this.loadCampsiteData(); + } else { + console.log("❌ User not authenticated in campsite page"); + this.user = null; + this.campsite = null; + this.loading = false; + this.error = null; + } + }); + } + + async loadCampsiteData() { + try { + console.log("🏕️ Campsite page loadCampsiteData called for:", this.siteid); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + console.log("🔑 Auth headers from campsite page:", headers); + + const response = await fetch(`/api/campsites/${this.siteid}`, { + headers, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.campsite = await response.json(); + console.log("🎯 Campsite loaded:", this.campsite?.name); + this.loading = false; + } catch (error) { + console.log("❌ Error loading campsite data:", error); + this.error = + error instanceof Error ? error.message : "Failed to load campsite data"; + this.loading = false; + } + } + + render() { + return html` ${this.renderBreadcrumb()} ${this.renderContent()} `; + } + + renderBreadcrumb() { + const campsiteName = this.campsite?.name || "Campsite"; + return html` + + + Adventure GuideParks → + ${campsiteName} + + `; + } + + renderContent() { + if (!this.user?.authenticated) { + const currentUrl = window.location.pathname + window.location.search; + const loginUrl = `/app/login?next=${encodeURIComponent(currentUrl)}`; + return html` +
+

+ Please to view + campsite details. +

+
+ `; + } + + if (this.loading) { + return html` +
+

Loading campsite data...

+
+ `; + } + + if (this.error) { + return html` +
+

Error loading campsite data: ${this.error}

+
+ `; + } + + if (!this.campsite) { + return html` +
+

Campsite not found.

+
+ `; + } + + return html` + + + ${this.renderImage()} + +
+ ${this.renderDescription()} ${this.renderMetaInfo()} + ${this.renderConnectedPaths()} ${this.renderNearbyPOI()} +
+ `; + } + + renderImage() { + if (!this.campsite?.card?.image) { + return ""; + } + + return html` +
+ ${this.campsite.card.imageAlt || this.campsite.name} +
+ `; + } + + renderDescription() { + if (!this.campsite?.description) { + return ""; + } + + return html`

${this.campsite.description}

`; + } + + renderMetaInfo() { + if (!this.campsite) return ""; + + return html` +
+ + + + + Capacity: ${this.campsite.capacity} + + + + + + ${this.campsite.location} + +
+ `; + } + + renderConnectedPaths() { + const paths = this.campsite?.connectedPaths || []; + + return html` +

Connected Paths

+
+ ${paths.length > 0 + ? paths.map( + (path) => html` + + + + + ${path.pathName} + + ` + ) + : html`No connected paths listed`} +
+ `; + } + + renderNearbyPOI() { + const poi = this.campsite?.nearbyPoi || []; + + return html` +

Nearby Points of Interest

+
+ ${poi.length > 0 + ? poi.map( + (poi) => html` + + + + + ${poi.poiName} + + ` + ) + : html`No POI listed`} +
+ `; + } +} + +export default CampsitePage; diff --git a/packages/app/src/pages/index.html b/packages/app/src/pages/index.html new file mode 100644 index 0000000..2197f04 --- /dev/null +++ b/packages/app/src/pages/index.html @@ -0,0 +1,22 @@ + + + + + + National Parks Adventure Guide + + + + + + + + +
+ + +
+
+
+ + diff --git a/packages/app/src/pages/main-page.ts b/packages/app/src/pages/main-page.ts new file mode 100644 index 0000000..048c285 --- /dev/null +++ b/packages/app/src/pages/main-page.ts @@ -0,0 +1,258 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +interface Park { + parkid: string; + name: string; + description?: string; + card?: { + title?: string; + description?: string; + image?: string; + imageAlt?: string; + }; +} + +@customElement("main-page") +class MainPage extends LitElement { + @state() + parks: Park[] = []; + + @state() + loading = true; + + @state() + error: string | null = null; + + @state() + authenticated = false; + + @state() + user: any = null; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + } + + .parks-section { + grid-column: 1 / -1; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg, 1rem); + margin: var(--spacing-md, 0.75rem) 0; + } + + .auth-message { + background-color: var(--color-background-secondary, #f5f5f5); + border-radius: var(--radius-md, 4px); + } + + .login-link { + color: var(--color-link, #0066cc); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + } + + .login-link:hover { + text-decoration: underline; + } + + /* Style the browse all parks link like the other card-style links */ + .browse-link { + display: block; + background-color: var(--color-background-section); + padding: var(--spacing-lg) var(--spacing-xl); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + margin: var(--spacing-lg) 0; + transition: all 0.2s ease; + color: var(--color-link); + text-decoration: underline; + text-decoration-color: var(--color-link); + text-underline-offset: 2px; + font-weight: var(--font-weight-semibold); + } + + .browse-link:hover { + border-color: var(--color-accent); + background-color: var(--color-background-card); + text-decoration: none; + transform: translateY(-1px); + box-shadow: var(--shadow-md); + color: var(--color-link-hover); + } + `, + ]; + + connectedCallback() { + super.connectedCallback(); + + // Set up auth observer + this._authObserver.observe((auth) => { + console.log("🔑 Main page auth observer fired:", auth); + const { user } = auth; + + if (user && user.authenticated) { + console.log("✅ User authenticated in main page, loading parks"); + this.authenticated = true; + this.user = user; + this.loadParks(); + } else { + console.log("❌ User not authenticated in main page"); + this.authenticated = false; + this.user = null; + this.parks = []; + this.loading = false; + this.error = null; + } + }); + } + + async loadParks() { + try { + console.log("🏞️ Main page loadParks called"); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + console.log("🔑 Auth headers from main page:", headers); + + const response = await fetch("/api/parks", { headers }); + console.log("📨 Response status:", response.status); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.parks = await response.json(); + console.log("🎯 Parks loaded in main page:", this.parks); + this.loading = false; + } catch (error) { + console.log("❌ Error loading parks in main page:", error); + this.error = + error instanceof Error ? error.message : "Failed to load parks"; + this.loading = false; + } + } + + render() { + return html` +
+ +

+ Browse all parks +

+
${this.renderParksContent()}
+
+ +
+ +

+ Plan your next adventure with our curated trip itineraries or create + your own. +

+ + + + + + +
+ `; + } + + renderParksContent() { + if (this.loading) { + return html` +
+

Loading parks...

+
+ `; + } + + if (this.error) { + if (this.error.includes("401")) { + return html` +
+

+ Please to view + parks and plan your adventure. +

+
+ `; + } else { + return html` +
+

Error loading parks: ${this.error}

+
+ `; + } + } + + if (this.parks.length === 0) { + return html` +
+

+ Please to view + parks and plan your adventure. +

+
+ `; + } + + return html` + + ${this.parks.map( + (park) => html` + + ` + )} + + `; + } +} + +export default MainPage; diff --git a/packages/app/src/pages/park-page.ts b/packages/app/src/pages/park-page.ts new file mode 100644 index 0000000..b262364 --- /dev/null +++ b/packages/app/src/pages/park-page.ts @@ -0,0 +1,517 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state, property } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +interface Park { + parkid: string; + name: string; + description?: string; + location?: string; + established?: string; + size?: string; +} + +interface Campsite { + siteid: string; + name: string; + description?: string; + card?: { + title?: string; + description?: string; + image?: string; + imageAlt?: string; + }; +} + +interface POI { + poiid: string; + name: string; + description?: string; + card?: { + title?: string; + description?: string; + image?: string; + imageAlt?: string; + }; +} + +interface Path { + pathid: string; + name: string; + description?: string; + type: "road" | "trail"; + card?: { + title?: string; + description?: string; + image?: string; + imageAlt?: string; + }; + image?: string; + imageAlt?: string; +} + +@customElement("park-page") +class ParkPage extends LitElement { + @property({ attribute: "park-id" }) + parkIdAttr?: string; + @state() + private _park: Park | null = null; + + @state() + private _campsites: Campsite[] = []; + + @state() + private _poi: POI[] = []; + + @state() + private _paths: Path[] = []; + + @state() + private _loading = true; + + @state() + private _error: string | null = null; + + @state() + private _user: any = null; + + parkid: string = ""; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + // Getters for properties + get park() { + return this._park; + } + set park(value: Park | null) { + this._park = value; + } + + get campsites() { + return this._campsites; + } + set campsites(value: Campsite[]) { + this._campsites = value; + } + + get poi() { + return this._poi; + } + set poi(value: POI[]) { + this._poi = value; + } + + get paths() { + return this._paths; + } + set paths(value: Path[]) { + this._paths = value; + } + + get loading() { + return this._loading; + } + set loading(value: boolean) { + this._loading = value; + } + + get error() { + return this._error; + } + set error(value: string | null) { + this._error = value; + } + + get user() { + return this._user; + } + set user(value: any) { + this._user = value; + } + + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg, 1rem); + margin: var(--spacing-md, 0.75rem) 0; + } + + .auth-message { + background-color: var(--color-background-secondary, #f5f5f5); + border-radius: var(--radius-md, 4px); + } + + .login-link { + color: var(--color-link, #0066cc); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + } + + .login-link:hover { + text-decoration: underline; + } + + .lead { + margin-bottom: var(--spacing-xl); + color: var(--color-text-light); + } + + .meta-row { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; + margin-bottom: var(--spacing-md); + } + + .chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: 9999px; + font-size: 0.875rem; + color: var(--color-text); + background: var(--color-background-card); + } + + .icon-sm { + width: 16px; + height: 16px; + fill: currentColor; + } + + h2 { + margin-top: var(--spacing-xl); + } + `, + iconStyles, + ]; + + connectedCallback() { + super.connectedCallback(); + + // Require attribute passed by router (no legacy URL parsing) + if (!this.parkIdAttr) { + console.error(" requires park-id attribute"); + this.loading = false; + this.error = "Missing park-id"; + return; + } + this.parkid = this.parkIdAttr; + + // Set up auth observer + this._authObserver.observe((auth) => { + console.log("🔑 Park page auth observer fired:", auth); + const { user } = auth; + + if (user && user.authenticated) { + console.log("✅ User authenticated in park page, loading data"); + this.user = user; + this.loadParkData(); + } else { + console.log("❌ User not authenticated in park page"); + this.user = null; + this.park = null; + this.campsites = []; + this.poi = []; + this.paths = []; + this.loading = false; + this.error = null; + } + }); + } + + async loadParkData() { + try { + console.log("🏞️ Park page loadParkData called for:", this.parkid); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + console.log("🔑 Auth headers from park page:", headers); + + // Load park details + const parkResponse = await fetch(`/api/parks/${this.parkid}`, { + headers, + }); + if (parkResponse.ok) { + this.park = await parkResponse.json(); + console.log("🎯 Park loaded:", this.park?.name); + } + + // Load campsites for this park + const campsitesResponse = await fetch( + `/api/campsites?park=${encodeURIComponent(this.parkid)}`, + { headers } + ); + if (campsitesResponse.ok) { + this.campsites = await campsitesResponse.json(); + console.log("🏕️ Campsites loaded:", this.campsites.length); + } + + // Load POI for this park + const poiResponse = await fetch( + `/api/poi?park=${encodeURIComponent(this.parkid)}`, + { headers } + ); + if (poiResponse.ok) { + this.poi = await poiResponse.json(); + console.log("📍 POI loaded:", this.poi.length); + } + + // Load paths for this park + const pathsResponse = await fetch( + `/api/paths?park=${encodeURIComponent(this.parkid)}`, + { headers } + ); + if (pathsResponse.ok) { + this.paths = await pathsResponse.json(); + console.log("🛤️ Paths loaded:", this.paths.length); + } + + this.loading = false; + } catch (error) { + console.log("❌ Error loading park data:", error); + this.error = + error instanceof Error ? error.message : "Failed to load park data"; + this.loading = false; + } + } + + get roads() { + return this.paths.filter((p) => p.type === "road"); + } + + get trails() { + return this.paths.filter((p) => p.type === "trail"); + } + + render() { + return html` ${this.renderBreadcrumb()} ${this.renderContent()} `; + } + + renderBreadcrumb() { + const parkName = this.park?.name || "Park"; + return html` + + + Adventure GuideParks → + ${parkName} + + `; + } + + renderContent() { + if (!this.user?.authenticated) { + const currentUrl = window.location.pathname + window.location.search; + const loginUrl = `/app/login?next=${encodeURIComponent(currentUrl)}`; + return html` +
+

+ Please to view + park details. +

+
+ `; + } + + if (this.loading) { + return html` +
+

Loading park data...

+
+ `; + } + + if (this.error) { + return html` +
+

Error loading park data: ${this.error}

+
+ `; + } + + if (!this.park) { + return html` +
+

Park not found.

+
+ `; + } + + return html` + + +
+ + + + + ${this.park.location} + + + + + + Est. ${this.park.established} + + + + + + ${this.park.size} + +
+ +

${this.park.description || ""}

+ +

Campsites

+ ${this.renderCampsites()} + +

Points of Interest

+ ${this.renderPOI()} + +

Paths

+
+ + ${this.renderRoads()} +
+
+ + ${this.renderTrails()} +
+ `; + } + + renderCampsites() { + if (this.campsites.length === 0) { + return html`

No campsites available.

`; + } + + return html` + + ${this.campsites.map( + (campsite) => html` + + ` + )} + + `; + } + + renderPOI() { + if (this.poi.length === 0) { + return html`

No points of interest available.

`; + } + + return html` + + ${this.poi.map( + (poi) => html` + + ` + )} + + `; + } + + renderRoads() { + if (this.roads.length === 0) { + return html`

No scenic roads available.

`; + } + + return html` + + ${this.roads.map((road) => this.renderPath(road))} + + `; + } + + renderTrails() { + if (this.trails.length === 0) { + return html`

No trails available.

`; + } + + return html` + + ${this.trails.map((trail) => this.renderPath(trail))} + + `; + } + + renderPath(path: Path) { + const hasImage = path.card?.image || path.image; + + if (hasImage) { + return html` + + `; + } else { + // For paths without images, we'll render with icon slot + return html` + + + + + + `; + } + } +} + +export default ParkPage; diff --git a/packages/app/src/pages/path-page.ts b/packages/app/src/pages/path-page.ts new file mode 100644 index 0000000..fc48634 --- /dev/null +++ b/packages/app/src/pages/path-page.ts @@ -0,0 +1,338 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state, property } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +interface Path { + pathid: string; + name: string; + description?: string; + type?: "road" | "trail"; + park?: string; + parkName?: string; + image?: string; + imageAlt?: string; + card?: { + image?: string; + imageAlt?: string; + }; +} + +@customElement("path-page") +class PathPage extends LitElement { + @property({ attribute: "path-id" }) + pathIdAttr?: string; + @state() + private _path: Path | null = null; + + @state() + private _loading = true; + + @state() + private _error: string | null = null; + + @state() + private _user: any = null; + + pathid: string = ""; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + // Getters for properties + get path() { + return this._path; + } + set path(value: Path | null) { + this._path = value; + } + + get loading() { + return this._loading; + } + set loading(value: boolean) { + this._loading = value; + } + + get error() { + return this._error; + } + set error(value: string | null) { + this._error = value; + } + + get user() { + return this._user; + } + set user(value: any) { + this._user = value; + } + + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg, 1rem); + margin: var(--spacing-md, 0.75rem) 0; + } + + .auth-message { + background-color: var(--color-background-secondary, #f5f5f5); + border-radius: var(--radius-md, 4px); + } + + .login-link { + color: var(--color-link, #0066cc); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + } + + .login-link:hover { + text-decoration: underline; + } + + .panel { + margin-top: var(--spacing-lg); + padding: var(--spacing-lg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-background-card); + } + + .lead { + color: var(--color-text-light); + margin-bottom: var(--spacing-md); + } + + .meta-row { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; + } + + .chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: 9999px; + font-size: 0.875rem; + color: var(--color-text); + background: var(--color-background); + } + + .icon-sm { + width: 16px; + height: 16px; + fill: currentColor; + } + + .path-image { + margin-bottom: var(--spacing-lg); + border-radius: var(--radius-md); + overflow: hidden; + } + + .path-image img { + width: 100%; + height: auto; + display: block; + border-radius: var(--radius-md); + } + `, + iconStyles, + ]; + + connectedCallback() { + super.connectedCallback(); + + // Require attribute passed by router (no legacy URL parsing) + if (!this.pathIdAttr) { + console.error(" requires path-id attribute"); + this.loading = false; + this.error = "Missing path-id"; + return; + } + this.pathid = this.pathIdAttr; + + // Set up auth observer + this._authObserver.observe((auth) => { + console.log("🔑 Path page auth observer fired:", auth); + const { user } = auth; + + if (user && user.authenticated) { + console.log("✅ User authenticated in path page, loading data"); + this.user = user; + this.loadPathData(); + } else { + console.log("❌ User not authenticated in path page"); + this.user = null; + this.path = null; + this.loading = false; + this.error = null; + } + }); + } + + async loadPathData() { + try { + console.log("🛤️ Path page loadPathData called for:", this.pathid); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + console.log("🔑 Auth headers from path page:", headers); + + const response = await fetch(`/api/paths/${this.pathid}`, { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.path = await response.json(); + console.log("🎯 Path loaded:", this.path?.name); + this.loading = false; + } catch (error) { + console.log("❌ Error loading path data:", error); + this.error = + error instanceof Error ? error.message : "Failed to load path data"; + this.loading = false; + } + } + + render() { + return html` ${this.renderBreadcrumb()} ${this.renderContent()} `; + } + + renderBreadcrumb() { + const pathName = this.path?.name || "Path"; + const parkName = this.path?.parkName || this.path?.park || "Park"; + const parkLink = this.path?.park ? `/app/parks/${this.path.park}` : "#"; + + return html` + + + Adventure GuideParks → + ${parkName} → + ${pathName} + + `; + } + + renderContent() { + if (!this.user?.authenticated) { + const currentUrl = window.location.pathname + window.location.search; + const loginUrl = `/app/login?next=${encodeURIComponent(currentUrl)}`; + return html` +
+

+ Please to view + path details. +

+
+ `; + } + + if (this.loading) { + return html` +
+

Loading path data...

+
+ `; + } + + if (this.error) { + return html` +
+

Error loading path data: ${this.error}

+
+ `; + } + + if (!this.path) { + return html` +
+

Path not found.

+
+ `; + } + + const iconType = this.path.type === "road" ? "road" : "hiking"; + + return html` + + +
+ ${this.renderImage()} ${this.renderDescription()} + ${this.renderMetaInfo()} +
+ `; + } + + renderImage() { + const imgSrc = this.path?.image || this.path?.card?.image; + const imgAlt = + this.path?.imageAlt || this.path?.card?.imageAlt || this.path?.name; + if (!imgSrc) { + return ""; + } + + return html` +
+ ${imgAlt} +
+ `; + } + + renderDescription() { + if (!this.path?.description) { + return ""; + } + + return html`

${this.path.description}

`; + } + + renderMetaInfo() { + if (!this.path) return ""; + + const parkName = this.path.parkName || this.path.park; + const iconType = this.path.type === "road" ? "icon-road" : "icon-hiking"; + + return html` +
+ + + + + ${this.path.type || "Path"} + + ${parkName + ? html` + + + + + Park: ${parkName} + + ` + : ""} +
+ `; + } +} + +export default PathPage; diff --git a/packages/app/src/pages/poi-page.ts b/packages/app/src/pages/poi-page.ts new file mode 100644 index 0000000..5afc7ea --- /dev/null +++ b/packages/app/src/pages/poi-page.ts @@ -0,0 +1,336 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state, property } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +interface POI { + poiid: string; + name: string; + description?: string; + type?: string; + park?: string; + parkName?: string; + image?: string; + imageAlt?: string; + card?: { + image?: string; + imageAlt?: string; + }; +} + +@customElement("poi-page") +class POIPage extends LitElement { + @property({ attribute: "poi-id" }) + poiIdAttr?: string; + @state() + private _poi: POI | null = null; + + @state() + private _loading = true; + + @state() + private _error: string | null = null; + + @state() + private _user: any = null; + + poiid: string = ""; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + // Getters for properties + get poi() { + return this._poi; + } + set poi(value: POI | null) { + this._poi = value; + } + + get loading() { + return this._loading; + } + set loading(value: boolean) { + this._loading = value; + } + + get error() { + return this._error; + } + set error(value: string | null) { + this._error = value; + } + + get user() { + return this._user; + } + set user(value: any) { + this._user = value; + } + + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg, 1rem); + margin: var(--spacing-md, 0.75rem) 0; + } + + .auth-message { + background-color: var(--color-background-secondary, #f5f5f5); + border-radius: var(--radius-md, 4px); + } + + .login-link { + color: var(--color-link, #0066cc); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); + } + + .login-link:hover { + text-decoration: underline; + } + + .panel { + margin-top: var(--spacing-lg); + padding: var(--spacing-lg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-background-card); + } + + .lead { + color: var(--color-text-light); + margin-bottom: var(--spacing-md); + } + + .meta-row { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; + } + + .chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: 9999px; + font-size: 0.875rem; + color: var(--color-text); + background: var(--color-background); + } + + .icon-sm { + width: 16px; + height: 16px; + fill: currentColor; + } + + .poi-image { + margin-bottom: var(--spacing-lg); + border-radius: var(--radius-md); + overflow: hidden; + } + + .poi-image img { + width: 100%; + height: auto; + display: block; + border-radius: var(--radius-md); + } + `, + iconStyles, + ]; + + connectedCallback() { + super.connectedCallback(); + + // Require attribute passed by router (no legacy URL parsing) + if (!this.poiIdAttr) { + console.error(" requires poi-id attribute"); + this.loading = false; + this.error = "Missing poi-id"; + return; + } + this.poiid = this.poiIdAttr; + + // Set up auth observer + this._authObserver.observe((auth) => { + console.log("🔑 POI page auth observer fired:", auth); + const { user } = auth; + + if (user && user.authenticated) { + console.log("✅ User authenticated in POI page, loading data"); + this.user = user; + this.loadPOIData(); + } else { + console.log("❌ User not authenticated in POI page"); + this.user = null; + this.poi = null; + this.loading = false; + this.error = null; + } + }); + } + + async loadPOIData() { + try { + console.log("📍 POI page loadPOIData called for:", this.poiid); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + console.log("🔑 Auth headers from POI page:", headers); + + const response = await fetch(`/api/poi/${this.poiid}`, { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.poi = await response.json(); + console.log("🎯 POI loaded:", this.poi?.name); + this.loading = false; + } catch (error) { + console.log("❌ Error loading POI data:", error); + this.error = + error instanceof Error ? error.message : "Failed to load POI data"; + this.loading = false; + } + } + + render() { + return html` ${this.renderBreadcrumb()} ${this.renderContent()} `; + } + + renderBreadcrumb() { + const poiName = this.poi?.name || "Point of Interest"; + const parkName = this.poi?.parkName || this.poi?.park || "Park"; + const parkLink = this.poi?.park ? `/app/parks/${this.poi.park}` : "#"; + + return html` + + + Adventure GuideParks → + ${parkName} → + ${poiName} + + `; + } + + renderContent() { + if (!this.user?.authenticated) { + const currentUrl = window.location.pathname + window.location.search; + const loginUrl = `/app/login?next=${encodeURIComponent(currentUrl)}`; + return html` +
+

+ Please to view + point of interest details. +

+
+ `; + } + + if (this.loading) { + return html` +
+

Loading POI data...

+
+ `; + } + + if (this.error) { + return html` +
+

Error loading POI data: ${this.error}

+
+ `; + } + + if (!this.poi) { + return html` +
+

Point of interest not found.

+
+ `; + } + + const iconType = this.poi.type === "campground" ? "tent" : "pin"; + + return html` + + + ${this.renderImage()} + +
+ ${this.renderDescription()} ${this.renderMetaInfo()} +
+ `; + } + + renderDescription() { + if (!this.poi?.description) { + return ""; + } + + return html`

${this.poi.description}

`; + } + + renderMetaInfo() { + if (!this.poi) return ""; + + const parkName = this.poi.parkName || this.poi.park; + + return html` +
+ + + + + ${this.poi.type || "Point of Interest"} + + ${parkName + ? html` + + + + + Park: ${parkName} + + ` + : ""} +
+ `; + } + + renderImage() { + const imgSrc = this.poi?.image || this.poi?.card?.image; + const imgAlt = + this.poi?.imageAlt || this.poi?.card?.imageAlt || this.poi?.name; + if (!imgSrc) return ""; + + return html` +
+ ${imgAlt} +
+ `; + } +} + +export default POIPage; diff --git a/packages/app/src/styles/icon-styles.css.ts b/packages/app/src/styles/icon-styles.css.ts new file mode 100644 index 0000000..a87d4a4 --- /dev/null +++ b/packages/app/src/styles/icon-styles.css.ts @@ -0,0 +1,30 @@ +import { css } from "lit"; + +export const iconStyles = css` + /* Icon sizing classes for Shadow DOM */ + .icon { + fill: var(--color-icon); + vertical-align: middle; + display: inline-block; + } + + .icon-sm { + width: 16px; + height: 16px; + } + + .icon-md { + width: 24px; + height: 24px; + } + + .icon-lg { + width: 32px; + height: 32px; + } + + .icon-xl { + width: 64px; + height: 64px; + } +`; \ No newline at end of file diff --git a/packages/app/src/styles/page-styles.css.ts b/packages/app/src/styles/page-styles.css.ts new file mode 100644 index 0000000..055d2a8 --- /dev/null +++ b/packages/app/src/styles/page-styles.css.ts @@ -0,0 +1,597 @@ +import { css } from "lit"; + +export const pageStyles = [ + css` + @import url("https://fonts.googleapis.com/css2?family=Knewave&display=swap"); + @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + `, + // Include all the CSS from the original files + css` + :root { + /* Color Palette - High Contrast Nature Theme */ + --color-primary: #0d4f3c; + /* Deep forest green */ + --color-primary-light: #1b6b47; + /* Medium forest green */ + --color-primary-dark: #062d23; + /* Very dark forest green */ + --color-accent: #ff6b35; + /* Bright orange (trail marker) */ + --color-accent-hover: #e55a2b; + /* Darker orange on hover */ + + /* Text Colors */ + --color-text: #1a1a1a; + /* Near black for maximum contrast */ + --color-text-light: #2d2d2d; + /* Dark gray for secondary text */ + --color-text-inverted: #ffffff; + /* Pure white for dark backgrounds */ + --color-text-muted: #666666; + /* Medium gray for muted text */ + + /* Background Colors */ + --color-background-page: #ffffff; + /* Pure white page background */ + --color-background-header: var(--color-primary); + /* Dark green header */ + --color-background-section: #ffffff; + /* White sections */ + --color-background-card: #f8f9fa; + /* Secondary background (used for messages, highlights) */ + --color-background-secondary: #f5f7fa; + /* Light gray cards */ + --color-background-anchor-highlight: var(--color-accent); + + /* Border Colors */ + --color-border: #c0c0c0; + /* Medium gray borders */ + --color-border-accent: var(--color-accent); + /* Orange accent borders */ + --color-border-light: #e8e8e8; + /* Light gray borders */ + + /* Link Colors */ + --color-link: #8b4513; + /* Brown links */ + --color-link-hover: #ff6b35; + /* Orange on hover */ + --color-link-visited: var(--color-primary-light); + /* Lighter green for visited links */ + + /* Status Colors */ + --color-success: #28a745; + /* Standard success green */ + --color-warning: #ffc107; + /* Standard warning yellow */ + --color-error: #dc3545; + /* Standard error red */ + + /* Icon Color */ + --color-icon: var(--color-text); + --color-icon-muted: var(--color-text-muted); + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-xxl: 48px; + + /* Typography */ + --font-family-display: "Knewave", cursive; + --font-family-primary: "Inter", -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, sans-serif; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.375rem; + --font-size-xxl: 1.75rem; + --font-size-xxxl: 2.25rem; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --line-height-tight: 1.2; + --line-height-base: 1.5; + --line-height-loose: 1.7; + + /* Border Radius */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 16px; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15); + --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.2); + } + `, + css` + body.dark-mode { + /* Dark Mode Color Palette - Nature Theme */ + --color-primary: #2e8b57; + /* Medium sea green for better contrast */ + --color-primary-light: #3cb371; + /* Lighter medium sea green */ + --color-primary-dark: #1e5a3a; + /* Darker green for depth */ + --color-accent: #ff6b35; + /* Bright orange (trail marker) */ + --color-accent-hover: #e55a2b; + /* Darker orange on hover */ + + /* Text Colors */ + --color-text: #f8f9fa; + /* Light gray for maximum contrast on dark backgrounds */ + --color-text-light: #e9ecef; + /* Muted gray for secondary text */ + --color-text-inverted: #000000; + /* Black for inverted text on light elements */ + --color-text-muted: #6c757d; + /* Muted gray */ + + /* Background Colors */ + --color-background-page: #0f1419; + /* Dark charcoal page background */ + --color-background-header: var(--color-primary); + /* Deep green header */ + --color-background-section: #1a1e23; + /* Dark slate sections */ + --color-background-card: #212529; + /* Secondary background for dark mode (messages, subtle panels) */ + --color-background-secondary: #0f1417; + /* Dark gray cards */ + --color-background-anchor-highlight: var(--color-accent); + + /* Border Colors */ + --color-border: #495057; + /* Medium dark gray borders */ + --color-border-accent: var(--color-accent); + /* Orange accent borders */ + --color-border-light: #343a40; + /* Lighter dark borders */ + + /* Link Colors */ + --color-link: var(--color-accent); + /* Orange links for visibility */ + --color-link-hover: var(--color-accent-hover); + /* Darker orange on hover */ + --color-link-visited: var(--color-primary-light); + /* Lighter green for visited links */ + + /* Status Colors */ + --color-success: #28a745; + /* Standard success green */ + --color-warning: #ffc107; + /* Standard warning yellow */ + --color-error: #dc3545; + /* Standard error red */ + + /* Icon Color */ + --color-icon: var(--color-text); + --color-icon-muted: var(--color-text-muted); + + /* Spacing, Typography, Border Radius, Shadows remain the same */ + } + `, + css` + @media (prefers-color-scheme: dark) { + body:not(.light-mode) { + /* Apply dark mode by default if system prefers dark, unless explicitly set to light */ + /* Ensure browser uses dark color rendering for form controls, scrollbars, etc. */ + color-scheme: dark; + } + } + `, + css` + html { + scroll-behavior: smooth; + } + + body { + background-color: var(--color-background-page); + color: var(--color-text); + font-family: var(--font-family-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-base); + margin: 0; + } + + /* Navigation styling */ + nav-element { + display: block; + width: 100%; + margin: 0 0 var(--spacing-xl) 0; + grid-column: 1 / -1; + } + + /* Typography */ + h1 { + color: var(--color-primary); + font-family: var(--font-family-display); + font-size: 3rem; + font-weight: var(--font-weight-bold); + line-height: var(--line-height-tight); + margin-bottom: var(--spacing-xxl); + margin-top: 0; + text-align: center; + border-bottom: 3px solid var(--color-accent); + padding-bottom: var(--spacing-lg); + } + + h2 { + color: var(--color-primary); + font-family: var(--font-family-display); + font-size: 2rem; + font-weight: var(--font-weight-bold); + line-height: var(--line-height-tight); + margin-top: 0; + margin-bottom: var(--spacing-xl); + padding-left: var(--spacing-md); + border-left: 4px solid var(--color-accent); + } + + /* Article and section styling */ + article { + background-color: var(--color-background-section); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + box-shadow: var(--shadow-md); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: var(--spacing-lg); + padding: 0 var(--spacing-lg); + } + + article > h1, + article > p { + grid-column: 1 / -1; + } + + section { + background-color: var(--color-background-card); + padding: var(--spacing-lg); + margin: var(--spacing-sm) 0; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; + box-sizing: border-box; + } + + /* Card component specific styles */ + card-grid { + display: block; + width: 100%; + box-sizing: border-box; + margin-bottom: var(--spacing-xl); + } + + card-element { + display: block; + box-sizing: border-box; + } + + /* Flexbox Layout System for Sparse Content */ + .flex-section { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); + } + + .flex-section.flex-row { + flex-direction: row; + flex-wrap: wrap; + align-items: stretch; + } + + .flex-section.flex-center { + align-items: center; + } + + .flex-item { + flex: 1; + min-width: 320px; + max-width: 100%; + } + + .flex-item.flex-shrink { + flex: 0 1 auto; + } + + .flex-item.flex-fixed { + flex: 0 0 auto; + } + + /* Responsive flex adjustments */ + @media (max-width: 768px) { + .flex-section.flex-row { + flex-direction: column; + } + + .flex-item { + min-width: 100%; + } + } + + li strong { + color: var(--color-accent); + font-weight: var(--font-weight-semibold); + } + + /* Link styling */ + a { + color: var(--color-link); + text-decoration: underline; + text-decoration-color: var(--color-link); + text-underline-offset: 2px; + font-weight: var(--font-weight-semibold); + transition: all 0.2s ease; + } + + a:hover { + color: var(--color-link-hover); + text-decoration-color: var(--color-link-hover); + } + + a:visited { + color: var(--color-link-visited); + } + + /* Card-style links for camping types */ + section p a, + section article h3 a { + display: block; + background-color: var(--color-background-section); + padding: var(--spacing-lg) var(--spacing-xl); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + margin: var(--spacing-lg) 0; + transition: all 0.2s ease; + text-decoration: none; + color: var(--color-link); + display: flex; + align-items: center; + gap: var(--spacing-md); + } + + section p a:hover, + section article h3 a:hover { + border-color: var(--color-accent); + background-color: var(--color-background-card); + text-decoration: none; + transform: translateY(-1px); + box-shadow: var(--shadow-md); + } + + section p a strong, + section article h3 a strong { + color: var(--color-primary); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + display: block; + margin-bottom: var(--spacing-sm); + } + + section p a em, + section article h3 a em { + color: var(--color-text-light); + font-style: italic; + font-size: var(--font-size-sm); + display: block; + } + + /* Anchor target highlighting */ + section:target { + background-color: var(--color-background-anchor-highlight); + border-color: var(--color-accent); + animation: anchor-highlight 1.5s ease-out; + } + + @keyframes anchor-highlight { + 0% { + background-color: var(--color-background-anchor-highlight); + } + + 100% { + background-color: var(--color-background-card); + } + } + + /* SVG Icon Classes */ + .icon { + fill: var(--color-icon); + vertical-align: middle; + display: inline-block; + flex-shrink: 0; + } + + .icon-sm { + width: 16px; + height: 16px; + } + + .icon-md { + width: 24px; + height: 16px; + } + + .icon-lg { + width: 32px; + height: 24px; + } + + .icon-xl { + width: 64px; + height: 32px; + } + + /* Responsive adjustments */ + @media (max-width: 768px) { + body { + padding: var(--spacing-lg); + } + } + + /* Card list styles - applies to all cards with lists */ + [class*="-card"] ul, + section ul { + list-style-type: disc; + margin-left: var(--spacing-lg); + padding-left: var(--spacing-sm); + } + + [class*="-card"] li, + section li { + margin-bottom: var(--spacing-xs); + } + + /* Authentication message styling */ + .auth-message { + margin: var(--spacing-lg) 0; + text-align: left; + } + + .auth-message p { + margin: 0; + color: var(--color-text); + } + + .auth-message .login-link { + color: var(--color-link); + text-decoration: underline; + font-weight: var(--font-weight-semibold); + border: none; + background: none; + padding: 0; + margin: 0; + display: inline; + } + + .auth-message .login-link:hover { + color: var(--color-link-hover); + } + + /* Form styling */ + form { + width: 100%; + } + + .auth-form { + max-width: 400px; + margin: 0 auto; + } + + .auth-form * { + text-align: left; + } + + form label { + display: block; + margin-bottom: var(--spacing-lg); + } + + form label span { + display: block; + margin-bottom: var(--spacing-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text); + text-align: left; + } + + form label input { + width: 100%; + padding: var(--spacing-md); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + font-family: var(--font-family-primary); + background-color: var(--color-background-page); + color: var(--color-text); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + box-sizing: border-box; + } + + form label input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); + } + + form label input::placeholder { + color: var(--color-text-muted); + } + + form button[type="submit"] { + background-color: var(--color-accent); + color: var(--color-text-inverted); + border: none; + padding: var(--spacing-md) var(--spacing-xl); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + font-family: var(--font-family-primary); + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; + margin-top: var(--spacing-lg); + } + + form button[type="submit"]:hover:not(:disabled) { + background-color: var(--color-accent-hover); + transform: translateY(-1px); + } + + form button[type="submit"]:disabled { + background-color: var(--color-border); + cursor: not-allowed; + transform: none; + } + + form .error:not(:empty) { + color: var(--color-error); + border: 1px solid var(--color-error); + background-color: rgba(220, 53, 69, 0.1); + border-radius: var(--radius-md); + padding: var(--spacing-md); + margin-top: var(--spacing-lg); + font-size: var(--font-size-sm); + } + + /* Auth message styling */ + .auth-message { + text-align: center; + padding: var(--spacing-xxl); + background-color: var(--color-background-card); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + margin: var(--spacing-xl) 0; + } + + .auth-message p { + font-size: var(--font-size-lg); + color: var(--color-text); + margin: 0; + } + + .login-link { + color: var(--color-accent); + text-decoration: none; + font-weight: var(--font-weight-semibold); + padding: var(--spacing-sm) var(--spacing-md); + border: 2px solid var(--color-accent); + border-radius: var(--radius-md); + transition: all 0.2s ease; + display: inline-block; + margin-left: var(--spacing-sm); + } + + .login-link:hover { + background-color: var(--color-accent); + color: var(--color-text-inverted); + transform: translateY(-1px); + } + `, +]; diff --git a/packages/app/src/styles/reset.css.ts b/packages/app/src/styles/reset.css.ts new file mode 100644 index 0000000..e39e6ae --- /dev/null +++ b/packages/app/src/styles/reset.css.ts @@ -0,0 +1,66 @@ +import { css } from "lit"; + +export const resetStyles = css` + * { + margin: 0; + padding: 0; + } + + ul, + ol { + list-style-type: none; + } + + html:focus-within { + scroll-behavior: smooth; + } + + html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + } + + body { + min-height: 100vh; + text-rendering: optimizeSpeed; + line-height: 1.5; + } + + img, + picture, + svg { + max-width: 100%; + height: auto; + display: block; + } + + button, + input, + optgroup, + select, + textarea { + font-family: inherit; + font-size: 100%; + line-height: 1.15; + margin: 0; + } + + button, + input { + overflow: visible; + } + + button, + select { + text-transform: none; + } + + table { + border-collapse: collapse; + border-spacing: 0; + } + + [hidden] { + display: none !important; + } +`; diff --git a/packages/app/src/styles/theme-tokens.css.ts b/packages/app/src/styles/theme-tokens.css.ts new file mode 100644 index 0000000..0648fea --- /dev/null +++ b/packages/app/src/styles/theme-tokens.css.ts @@ -0,0 +1,172 @@ +import { css } from "lit"; + +export const themeTokens = css` + :host { + /* Color Palette - High Contrast Nature Theme */ + --color-primary: #0D4F3C; + /* Deep forest green */ + --color-primary-light: #1B6B47; + /* Medium forest green */ + --color-primary-dark: #062D23; + /* Very dark forest green */ + --color-accent: #FF6B35; + /* Bright orange (trail marker) */ + --color-accent-hover: #E55A2B; + /* Darker orange on hover */ + + /* Text Colors */ + --color-text: #1A1A1A; + /* Near black for maximum contrast */ + --color-text-light: #2D2D2D; + /* Dark gray for secondary text */ + --color-text-inverted: #FFFFFF; + /* Pure white for dark backgrounds */ + --color-text-muted: #666666; + /* Medium gray for muted text */ + + /* Background Colors */ + --color-background-page: #FFFFFF; + /* Pure white page background */ + --color-background-header: var(--color-primary); + /* Dark green header */ + --color-background-section: #FFFFFF; + /* White sections */ + --color-background-card: #F8F9FA; + /* Secondary background (used for messages, highlights) */ + --color-background-secondary: #F5F7FA; + /* Light gray cards */ + --color-background-anchor-highlight: var(--color-accent); + + /* Border Colors */ + --color-border: #C0C0C0; + /* Medium gray borders */ + --color-border-accent: var(--color-accent); + /* Orange accent borders */ + --color-border-light: #E8E8E8; + /* Light gray borders */ + + /* Link Colors */ + --color-link: #8B4513; + /* Brown links */ + --color-link-hover: #FF6B35; + /* Orange on hover */ + --color-link-visited: var(--color-primary-light); + /* Lighter green for visited links */ + + /* Status Colors */ + --color-success: #28A745; + /* Standard success green */ + --color-warning: #FFC107; + /* Standard warning yellow */ + --color-error: #DC3545; + /* Standard error red */ + + /* Icon Color */ + --color-icon: var(--color-text); + --color-icon-muted: var(--color-text-muted); + + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-xxl: 48px; + + /* Typography */ + --font-family-display: 'Knewave', cursive; + --font-family-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.375rem; + --font-size-xxl: 1.75rem; + --font-size-xxxl: 2.25rem; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --line-height-tight: 1.2; + --line-height-base: 1.5; + --line-height-loose: 1.7; + + /* Border Radius */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 16px; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15); + --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.20); + } + + :host(.dark-mode), + :host-context(body.dark-mode) { + /* Dark Mode Color Palette - Nature Theme */ + --color-primary: #2E8B57; + /* Medium sea green for better contrast */ + --color-primary-light: #3CB371; + /* Lighter medium sea green */ + --color-primary-dark: #1E5A3A; + /* Darker green for depth */ + --color-accent: #FF6B35; + /* Bright orange (trail marker) */ + --color-accent-hover: #E55A2B; + /* Darker orange on hover */ + + /* Text Colors */ + --color-text: #F8F9FA; + /* Light gray for maximum contrast on dark backgrounds */ + --color-text-light: #E9ECEF; + /* Muted gray for secondary text */ + --color-text-inverted: #000000; + /* Black for inverted text on light elements */ + --color-text-muted: #6C757D; + /* Muted gray */ + + /* Background Colors */ + --color-background-page: #0F1419; + /* Dark charcoal page background */ + --color-background-header: var(--color-primary); + /* Deep green header */ + --color-background-section: #1A1E23; + /* Dark slate sections */ + --color-background-card: #212529; + /* Secondary background for dark mode (messages, subtle panels) */ + --color-background-secondary: #0f1417; + /* Dark gray cards */ + --color-background-anchor-highlight: var(--color-accent); + + /* Border Colors */ + --color-border: #495057; + /* Medium dark gray borders */ + --color-border-accent: var(--color-accent); + /* Orange accent borders */ + --color-border-light: #343A40; + /* Lighter dark borders */ + + /* Link Colors */ + --color-link: var(--color-accent); + /* Orange links for visibility */ + --color-link-hover: var(--color-accent-hover); + /* Darker orange on hover */ + --color-link-visited: var(--color-primary-light); + /* Lighter green for visited links */ + + /* Status Colors */ + --color-success: #28A745; + /* Standard success green */ + --color-warning: #FFC107; + /* Standard warning yellow */ + --color-error: #DC3545; + /* Standard error red */ + + /* Icon Color */ + --color-icon: var(--color-text); + --color-icon-muted: var(--color-text-muted); + + /* Spacing, Typography, Border Radius, Shadows remain the same */ + } +`; \ No newline at end of file diff --git a/packages/app/src/update.ts b/packages/app/src/update.ts new file mode 100644 index 0000000..e2ddaf9 --- /dev/null +++ b/packages/app/src/update.ts @@ -0,0 +1,126 @@ +import { Auth } from "@calpoly/mustang"; +import type { Msg } from "./messages"; +import type { Model } from "./model"; + +type None = []; +type ThenUpdate = [M, ...Array>]; + +export default function update( + message: Msg, + model: Model, + user: Auth.User +): Model | ThenUpdate { + const [command, payload] = message as any; + switch (command) { + case "itinerary/create": { + const { itinerary } = payload; + const cb = (payload.callbacks || {}) as { + onSuccess?: () => void; + onFailure?: (err: Error) => void; + }; + return [ + { ...model }, + createItinerary(itinerary, user) + .then(() => { + if (cb.onSuccess) cb.onSuccess(); + return [] as None; + }) + .catch((err) => { + if (cb.onFailure) cb.onFailure(err); + return [] as None; + }), + ]; + } + case "itinerary/update": { + const { itinerary } = payload; + const cb = (payload.callbacks || {}) as { + onSuccess?: () => void; + onFailure?: (err: Error) => void; + }; + return [ + { ...model }, + updateItinerary(itinerary, user) + .then(() => { + if (cb.onSuccess) cb.onSuccess(); + return [] as None; + }) + .catch((err) => { + if (cb.onFailure) cb.onFailure(err); + return [] as None; + }), + ]; + } + case "itinerary/delete": { + const { itineraryid } = payload; + const cb = (payload.callbacks || {}) as { + onSuccess?: () => void; + onFailure?: (err: Error) => void; + }; + return [ + { ...model }, + deleteItinerary(itineraryid, user) + .then(() => { + if (cb.onSuccess) cb.onSuccess(); + return [] as None; + }) + .catch((err) => { + if (cb.onFailure) cb.onFailure(err); + return [] as None; + }), + ]; + } + default: { + const unhandled: never = command as never; + throw new Error(`Unhandled message "${unhandled}"`); + } + } +} + +function createItinerary( + itinerary: any | any[], + user: Auth.User +): Promise { + const items = Array.isArray(itinerary) ? itinerary : [itinerary]; + return Promise.all( + items.map((item) => + fetch(`/api/itineraries`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...Auth.headers(user), + }, + body: JSON.stringify(item), + }).then((response: Response) => { + if (response.ok) return; + throw new Error(`Failed to create itinerary (HTTP ${response.status})`); + }) + ) + ).then(() => {}); +} + +function updateItinerary(itinerary: any, user: Auth.User): Promise { + const id = itinerary.itineraryid || itinerary.id; + return fetch(`/api/itineraries/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...Auth.headers(user), + }, + body: JSON.stringify(itinerary), + }).then((response: Response) => { + if (response.ok) return; + throw new Error(`Failed to update itinerary (HTTP ${response.status})`); + }); +} + +function deleteItinerary(itineraryid: string, user: Auth.User): Promise { + return fetch(`/api/itineraries/${itineraryid}`, { + method: "DELETE", + headers: { + ...Auth.headers(user), + }, + }).then((response: Response) => { + if (response.ok) return; + throw new Error(`Failed to delete itinerary (HTTP ${response.status})`); + }); +} diff --git a/packages/app/src/views/home-view.ts b/packages/app/src/views/home-view.ts new file mode 100644 index 0000000..6992a35 --- /dev/null +++ b/packages/app/src/views/home-view.ts @@ -0,0 +1,102 @@ +import { LitElement, html, css } from "lit"; +import { customElement } from "lit/decorators.js"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +@customElement("home-view") +class HomeViewElement extends LitElement { + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + grid-column: 1 / -1; + } + + h1 { + font-size: var(--font-size-2xl, 2rem); + margin-bottom: var(--spacing-xl, 1.5rem); + color: var(--color-text-primary); + grid-column: 1 / -1; + } + + section { + grid-column: 1 / -1; + margin-bottom: var(--spacing-xl); + } + `, + iconStyles, + ]; + + render() { + return html` + + Adventure Guide + + +
+ + + + + + +
+ +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ `; + } +} + +export default HomeViewElement; diff --git a/packages/app/src/views/itinerary-create-view.ts b/packages/app/src/views/itinerary-create-view.ts new file mode 100644 index 0000000..68d2908 --- /dev/null +++ b/packages/app/src/views/itinerary-create-view.ts @@ -0,0 +1,803 @@ +import { html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { Observer, Auth, Form, define, View, History } from "@calpoly/mustang"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import "../components/form-section"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; +import { Msg } from "../messages.ts"; + +interface OptionItem { + id: string; + name: string; +} + +interface ActivityDraft { + time: string; + activity: string; + location: string; + description?: string; + pathId?: string; + poiId?: string; + campsiteId?: string; +} + +@customElement("itinerary-create-view") +export class ItineraryCreateView extends View { + static uses = define({ "mu-form": Form.Element }); + constructor() { + super("natty:model"); + } + + // Auth state + user: any = null; + _authObserver: Observer = new Observer(this, "natty:auth"); + + // Form state + @property() submitting = false; + @property() error: string | null = null; + @property() success: string | null = null; + + // Options + @property() parks: OptionItem[] = []; + @property() paths: OptionItem[] = []; + @property() pois: OptionItem[] = []; + @property() campsites: OptionItem[] = []; + + // Trip details + @property() tripid = ""; + @property() tripName = ""; + @property() day = 1; + @property() date = ""; + @property() notes = ""; + @property() parkId = ""; + @property() campsiteId = ""; + // Multi-day planning + @property() tripLength = 1; + @property() daysDraft: Array<{ + day: number; + date: string; + notes: string; + activities: ActivityDraft[]; + draft: { + time: string; + activity: string; + location: string; + locationValue: string; + description: string; + selectedReferenceType: string; + selectedReferenceId: string; + }; + }> = [ + { + day: 1, + date: "", + notes: "", + activities: [], + draft: { + time: "", + activity: "", + location: "", + locationValue: "", + description: "", + selectedReferenceType: "", + selectedReferenceId: "", + }, + }, + ]; + + private defaultDay(dayNum: number) { + return { + day: dayNum, + date: "", + notes: "", + activities: [], + draft: { + time: "", + activity: "", + location: "", + locationValue: "", + description: "", + selectedReferenceType: "", + selectedReferenceId: "", + }, + }; + } + + private addDays(base: string, days: number): string { + // base is YYYY-MM-DD + if (!base) return ""; + const d = new Date(base + "T00:00:00"); + if (isNaN(d.getTime())) return ""; + d.setDate(d.getDate() + days); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; + } + + private updateAutoDates() { + if (!this.date || this.tripLength < 1) return; + this.daysDraft = this.daysDraft.map((d, i) => ({ + ...d, + date: this.addDays(this.date, i) || d.date, + })); + } + + // Activities (legacy field kept for compatibility if needed) + @property() activitiesDraft: ActivityDraft[] = []; + + connectedCallback(): void { + super.connectedCallback(); + this._authObserver.observe(({ user }) => { + this.user = user; + if (user) this.loadOptions(); + }); + } + + updated(): void { + // forcibly hide mu-form's built-in submit area because i want my own button + const mf = this.renderRoot?.querySelector("mu-form") as + | (HTMLElement & { shadowRoot?: ShadowRoot }) + | null; + const sr = mf?.shadowRoot; + if (sr) { + const submitPart = sr.querySelector( + '[part="submit"]' + ) as HTMLElement | null; + const submitBtn = sr.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement | null; + if (submitPart) submitPart.style.display = "none"; + if (submitBtn) submitBtn.style.display = "none"; + } + } + + async loadOptions() { + try { + const headers = Auth.headers(this.user); + const [parksRes, pathsRes, poisRes, campsRes] = await Promise.all([ + fetch("/api/parks", { headers }), + fetch("/api/paths", { headers }), + fetch("/api/poi", { headers }), + fetch("/api/campsites", { headers }), + ]); + const parksJson = await parksRes.json(); + const pathsJson = await pathsRes.json(); + const poisJson = await poisRes.json(); + const campsJson = await campsRes.json(); + this.parks = parksJson.map((p: any) => ({ + id: p.parkid || p.id, + name: p.name, + })); + this.paths = pathsJson.map((p: any) => ({ id: p.pathid, name: p.name })); + this.pois = poisJson.map((p: any) => ({ id: p.poiid, name: p.name })); + this.campsites = campsJson.map((c: any) => ({ + id: c.siteid, + name: c.name, + })); + } catch (e) { + this.error = "Failed to load options"; + } + } + + private async loadParkScopedOptions(parkId: string) { + if (!parkId) return; + try { + const headers = Auth.headers(this.user); + // Use query-based endpoints provided by server: /api/paths?park=ID, etc. + const [pathsRes, poisRes, campsRes] = await Promise.all([ + fetch(`/api/paths?park=${encodeURIComponent(parkId)}`, { headers }), + fetch(`/api/poi?park=${encodeURIComponent(parkId)}`, { headers }), + fetch(`/api/campsites?park=${encodeURIComponent(parkId)}`, { headers }), + ]); + if (pathsRes.ok) { + const pathsJson = await pathsRes.json(); + this.paths = pathsJson.map((p: any) => ({ + id: p.pathid, + name: p.name, + })); + } + if (poisRes.ok) { + const poisJson = await poisRes.json(); + this.pois = poisJson.map((p: any) => ({ id: p.poiid, name: p.name })); + } + if (campsRes.ok) { + const campsJson = await campsRes.json(); + this.campsites = campsJson.map((c: any) => ({ + id: c.siteid, + name: c.name, + })); + } + } catch (_e) { + // Keep global lists if scoped fetch fails + } + } + + static styles = [ + themeTokens, + ...pageStyles, + iconStyles, + css` + /* Shell container centers content; host element of mu-form should not affect layout */ + .form-shell { + max-width: 1040px; + margin: 0 auto; + } + /* Keep mu-form as a block to avoid submit area stretching in grids */ + mu-form { + display: block; + margin-top: var(--spacing-lg); + } + /* Built-in mu-form submit UI suppressed via slot/runtime; no extra styles needed here */ + label { + display: block; + width: 100%; + margin-bottom: var(--spacing-md); + line-height: 1.25; + white-space: normal; + } + select, + input, + textarea { + display: block; + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: var(--spacing-md); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + } + fieldset { + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--spacing-md); + } + .activity-grid { + display: grid; + grid-template-columns: 1fr 1fr 2fr; + gap: var(--spacing-md); + align-items: end; + } + .sections-grid { + display: grid; + grid-template-columns: repeat( + auto-fit, + minmax(var(--section-min-col, 480px), 1fr) + ); + gap: var(--spacing-2xl); + align-items: start; + margin-bottom: var(--spacing-xl); + } + .submit-actions { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-md); + margin-top: var(--spacing-2xl); + text-align: center; + } + .submit-actions button[type="button"], + .submit-actions button[type="submit"], + .submit-actions .primary-btn { + appearance: none; + border: none; + border-radius: var(--radius-md); + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--color-accent); + color: var(--color-elevated-text); + font-weight: 600; + cursor: pointer; + transition: transform 120ms ease, filter 120ms ease, opacity 120ms ease; + } + .submit-actions .primary-btn:hover { + filter: brightness(1.05); + } + @media (max-width: 800px) { + .activity-grid { + grid-template-columns: 1fr 1fr; + } + } + @media (max-width: 520px) { + .activity-grid { + grid-template-columns: 1fr; + } + } + .actions { + display: flex; + gap: var(--spacing-md); + align-items: center; + flex-wrap: wrap; + } + .muted { + color: var(--color-text-muted); + } + .activity-list { + margin-top: var(--spacing-md); + display: grid; + gap: var(--spacing-sm); + } + .activity-list li { + display: flex; + align-items: center; + gap: var(--spacing-sm); + } + .reference-select { + display: grid; + grid-template-columns: 1fr 2fr; + gap: var(--spacing-sm); + align-items: end; + } + `, + ]; + + render() { + if (!this.user) { + return html` + + Adventure GuideCreate Trip + + +

Please log in to create an itinerary.

+ `; + } + + return html` + + Adventure GuideTrips → + New + + +
+ +
+ + Trip Details +
+ + + + + +
+
+ + ${this.daysDraft.map( + (d, di) => html` + + Day ${d.day} +
+ + + +
+ + + +
+ + +
+ + Plan multiple activities for this day. +
+ + ${d.activities.length + ? html`
    + ${d.activities.map( + (a: ActivityDraft, idx: number) => { + const referenceInfo = this.getReferenceInfo(a); + return html`
  • + ${a.activity} at ${a.location} + • ${a.time} + ${referenceInfo + ? html` • ${referenceInfo}` + : ""} + ${a.description + ? html`
    ${a.description}` + : ""} + +
  • `; + } + )} +
` + : html`

+ No activities added yet for Day ${d.day}. +

`} +
+
+ ` + )} +
+ + +
+
+ + ${this.error ? html`${this.error}` : ""} + ${ + this.success ? html`${this.success}` : "" + } +
+ `; + } + + private getReferenceInfo(activity: ActivityDraft): string | undefined { + if (activity.pathId) { + const path = this.paths.find((p) => p.id === activity.pathId); + return path ? `Path: ${path.name}` : `Path ID: ${activity.pathId}`; + } + if (activity.poiId) { + const poi = this.pois.find((p) => p.id === activity.poiId); + return poi ? `POI: ${poi.name}` : `POI ID: ${activity.poiId}`; + } + if (activity.campsiteId) { + const campsite = this.campsites.find((c) => c.id === activity.campsiteId); + return campsite + ? `Campsite: ${campsite.name}` + : `Campsite ID: ${activity.campsiteId}`; + } + return undefined; + } + + private addActivityToDay = (dayIndex: number) => { + const day = this.daysDraft[dayIndex]; + const draft = day?.draft; + if (!draft || !draft.time || !draft.activity || !draft.location) { + this.error = "Time, activity name, and location are required"; + return; + } + + const activity: ActivityDraft = { + time: draft.time, + activity: draft.activity, + location: draft.location, + description: draft.description || undefined, + }; + + if (draft.selectedReferenceType && draft.selectedReferenceId) { + if (draft.selectedReferenceType === "path") { + activity.pathId = draft.selectedReferenceId; + } else if (draft.selectedReferenceType === "poi") { + activity.poiId = draft.selectedReferenceId; + } else if (draft.selectedReferenceType === "campsite") { + activity.campsiteId = draft.selectedReferenceId; + } + } + + const nextDays = this.daysDraft.map((d, idx) => + idx === dayIndex + ? { + ...d, + activities: [...d.activities, activity], + draft: { + ...d.draft, + time: "", + activity: "", + location: "", + locationValue: "", + description: "", + selectedReferenceType: "", + selectedReferenceId: "", + }, + } + : d + ); + this.daysDraft = nextDays; + + this.error = null; + }; + + private removeActivityFromDay = (dayIndex: number, idx: number) => { + this.daysDraft = this.daysDraft.map((d, di) => + di === dayIndex + ? { ...d, activities: d.activities.filter((_, i) => i !== idx) } + : d + ); + }; + + // Submit is handled by mu-form's @mu-form:submit only + + private async onSubmitMu(_e: Form.SubmitEvent) { + this.error = null; + this.success = null; + this.submitting = true; + const campsiteName = this.campsiteId + ? this.campsites.find((c) => c.id === this.campsiteId)?.name + : undefined; + + const itineraries = this.daysDraft.map((d) => { + const itineraryid = `${this.tripid}-day${d.day}`; + return { + itineraryid, + tripid: this.tripid, + tripName: this.tripName, + day: d.day, + date: d.date, + notes: d.notes || undefined, + campsiteId: this.campsiteId || undefined, + campsiteName, + activities: [...d.activities], + card: { + title: `Day ${d.day}: ${d.activities[0]?.activity || "Itinerary"}`, + description: d.notes || "User-created itinerary", + href: `/app/trips/${this.tripid}/itinerary/day${d.day}`, + }, + }; + }); + + const message: [ + "itinerary/create", + { + itinerary: any[]; + callbacks?: { + onSuccess?: () => void; + onFailure?: (err: Error) => void; + }; + } + ] = [ + "itinerary/create", + { + itinerary: itineraries, + callbacks: { + onSuccess: () => { + this.success = "Itineraries created!"; + this.submitting = false; + History.dispatch(this, "history/navigate", { href: "/app/trips" }); + // Reset form fields + this.tripid = ""; + this.tripName = ""; + this.day = 1; + this.date = ""; + this.notes = ""; + this.campsiteId = ""; + this.tripLength = 1; + this.daysDraft = [this.defaultDay(1)]; + }, + onFailure: (err: Error) => { + this.error = err.message || "Failed to create itineraries"; + this.submitting = false; + }, + }, + }, + ]; + // Debug: log before dispatching message + console.log("[itinerary-create-view] dispatching mu:message", message); + this.dispatchMessage(message); + } +} + +export default ItineraryCreateView; diff --git a/packages/app/src/views/itinerary-view.ts b/packages/app/src/views/itinerary-view.ts new file mode 100644 index 0000000..d9a0568 --- /dev/null +++ b/packages/app/src/views/itinerary-view.ts @@ -0,0 +1,399 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +import "../components/card.ts"; + +interface Itinerary { + itineraryid: string; + tripid: string; + tripName: string; + day: number; + date: string; + activities: Array<{ + time: string; + activity: string; + location: string; + description?: string; + pathId?: string; + poiId?: string; + campsiteId?: string; + }>; + campsiteId?: string; + campsiteName?: string; + notes?: string; + card: { + title: string; + description: string; + href: string; + }; +} + +interface TripGroup { + tripid: string; + tripName: string; + itineraries: Itinerary[]; +} + +@customElement("itinerary-view") +class ItineraryViewElement extends LitElement { + @state() + itineraries: Itinerary[] = []; + + @state() + loading = true; + + @state() + error: string | null = null; + + @state() + user: any = null; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + connectedCallback() { + super.connectedCallback(); + + this._authObserver.observe(({ user }) => { + if (user) { + this.user = user; + this.loadItineraries(); + } else { + this.user = null; + this.loading = false; + } + }); + } + + async loadItineraries() { + try { + console.log("📅 Loading itineraries"); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + const response = await fetch("/api/itineraries", { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.itineraries = await response.json(); + console.log("✅ Loaded", this.itineraries.length, "itineraries"); + this.loading = false; + } catch (error) { + console.log("❌ Error loading itineraries:", error); + this.error = + error instanceof Error ? error.message : "Failed to load itineraries"; + this.loading = false; + } + } + + async deleteItinerary(itineraryid: string) { + if (!this.user) return; + const confirmDelete = window.confirm( + `Delete itinerary ${itineraryid}? This cannot be undone.` + ); + if (!confirmDelete) return; + + try { + const headers = Auth.headers(this.user); + const res = await fetch( + `/api/itineraries/${encodeURIComponent(itineraryid)}`, + { + method: "DELETE", + headers, + } + ); + if (res.status === 204) { + this.itineraries = this.itineraries.filter( + (i) => i.itineraryid !== itineraryid + ); + } else if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + } catch (err) { + this.error = err instanceof Error ? err.message : String(err); + } + } + + groupByTrip(): TripGroup[] { + const groups = new Map(); + + this.itineraries.forEach((itinerary) => { + if (!groups.has(itinerary.tripid)) { + groups.set(itinerary.tripid, { + tripid: itinerary.tripid, + tripName: itinerary.tripName, + itineraries: [], + }); + } + groups.get(itinerary.tripid)!.itineraries.push(itinerary); + }); + + // Sort itineraries within each group by day + groups.forEach((group) => { + group.itineraries.sort((a, b) => a.day - b.day); + }); + + return Array.from(groups.values()); + } + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + grid-column: 1 / -1; + } + + section { + margin-bottom: var(--spacing-xl); + } + + .trip-group { + margin-bottom: var(--spacing-xl); + padding: var(--spacing-lg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + } + + .trip-group h3 { + margin-top: 0; + color: var(--color-primary); + } + + .trip-group h3 a { + color: var(--color-link); + text-decoration: underline; + text-decoration-color: var(--color-link); + text-underline-offset: 2px; + font-weight: var(--font-weight-semibold); + transition: all 0.2s ease; + } + + .trip-group h3 a:hover { + color: var(--color-link-hover); + text-decoration-color: var(--color-link-hover); + } + + .trip-group h3 a:visited { + color: var(--color-link-visited); + } + + .activity-list { + list-style: none; + padding: 0; + margin: var(--spacing-sm) 0; + } + + .activity-item { + display: flex; + gap: var(--spacing-sm); + padding: var(--spacing-xs) 0; + border-bottom: 1px solid var(--color-border-light); + } + + .activity-item:last-child { + border-bottom: none; + } + + .activity-time { + font-weight: var(--font-weight-semibold); + color: var(--color-primary); + min-width: 80px; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg); + margin: var(--spacing-md) 0; + } + + .auth-message { + background-color: var(--color-background-secondary); + border-radius: var(--radius-md); + } + + .login-link { + color: var(--color-link); + text-decoration: none; + font-weight: var(--font-weight-semibold); + } + + .login-link:hover { + text-decoration: underline; + } + `, + iconStyles, + ]; + + render() { + return html` + + + + Adventure GuideTrips → + Itineraries + + + +

Detailed day-by-day itineraries for your adventures.

+ + ${this.renderContent()} + `; + } + + renderContent() { + if (this.loading) { + return html` +
+

Loading itineraries...

+
+ `; + } + + if (this.error) { + if (this.error.includes("401")) { + return html` +
+

+ Please to view + trip itineraries. +

+
+ `; + } else { + return html` +
+

Error loading itineraries: ${this.error}

+
+ `; + } + } + + if (this.itineraries.length === 0) { + return html` +
+

No itineraries found. Start planning your trip!

+
+ `; + } + + const tripGroups = this.groupByTrip(); + + return html` +
+ ${tripGroups.map( + (group) => html` +
+

+ ${group.tripName} +

+

${group.itineraries.length} days planned

+ + + ${group.itineraries.map( + (itinerary) => html` + ${itinerary.campsiteName}` + : itinerary.campsiteName} +

` + : ""} + +
Activities:
+
    + ${itinerary.activities.map( + (activity) => html` +
  • + ${activity.time} +
    + ${activity.activity} at + ${activity.pathId + ? html`${activity.location}` + : activity.poiId + ? html`${activity.location}` + : activity.campsiteId + ? html`${activity.location}` + : activity.location} + ${activity.description + ? html`
    ${activity.description}` + : ""} +
    +
  • + ` + )} +
+ + ${itinerary.notes + ? html`

+ Notes: ${itinerary.notes} +

` + : ""} +
+ `}" + > +
+ +
+ + ` + )} + +
+ ` + )} + + `; + } +} + +export default ItineraryViewElement; diff --git a/packages/app/src/views/login-view.ts b/packages/app/src/views/login-view.ts new file mode 100644 index 0000000..3f8fd7d --- /dev/null +++ b/packages/app/src/views/login-view.ts @@ -0,0 +1,49 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import "../auth/login-form.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +@customElement("login-view") +class LoginViewElement extends LitElement { + next: string = "/app"; + + static styles = [themeTokens, ...pageStyles]; + + connectedCallback(): void { + super.connectedCallback(); + const url = new URL(window.location.href); + const n = url.searchParams.get("next"); + if (n) this.next = n; + } + + render() { + return html` + + + Adventure Guide → + Sign In + + + + +
e.preventDefault()}> +
+ + + + + +
+
+ `; + } +} + +export default LoginViewElement; \ No newline at end of file diff --git a/packages/app/src/views/parks-view.ts b/packages/app/src/views/parks-view.ts new file mode 100644 index 0000000..ca63f67 --- /dev/null +++ b/packages/app/src/views/parks-view.ts @@ -0,0 +1,37 @@ +import { LitElement, html, css } from "lit"; +import { customElement } from "lit/decorators.js"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +@customElement("parks-view") +class ParksViewElement extends LitElement { + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + grid-column: 1 / -1; + } + section-header { + margin-bottom: var(--spacing-lg); + } + `, + iconStyles, + ]; + + render() { + return html` + + Adventure Guide → + Parks + + + + + `; + } +} + +export default ParksViewElement; diff --git a/packages/app/src/views/paths-view.ts b/packages/app/src/views/paths-view.ts new file mode 100644 index 0000000..ac9d3fc --- /dev/null +++ b/packages/app/src/views/paths-view.ts @@ -0,0 +1,37 @@ +import { LitElement, html, css } from "lit"; +import { customElement } from "lit/decorators.js"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +@customElement("paths-view") +class PathsViewElement extends LitElement { + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + grid-column: 1 / -1; + } + section-header { + margin-bottom: var(--spacing-lg); + } + `, + iconStyles, + ]; + + render() { + return html` + + Adventure Guide → + Paths + + + + + `; + } +} + +export default PathsViewElement; diff --git a/packages/app/src/views/poi-view.ts b/packages/app/src/views/poi-view.ts new file mode 100644 index 0000000..4e68ad8 --- /dev/null +++ b/packages/app/src/views/poi-view.ts @@ -0,0 +1,40 @@ +import { LitElement, html, css } from "lit"; +import { customElement } from "lit/decorators.js"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +@customElement("poi-view") +class PoiViewElement extends LitElement { + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + grid-column: 1 / -1; + } + section-header { + margin-bottom: var(--spacing-lg); + } + `, + iconStyles, + ]; + + render() { + return html` + + Adventure Guide → + Points of Interest + + + + + `; + } +} + +export default PoiViewElement; diff --git a/packages/app/src/views/register-view.ts b/packages/app/src/views/register-view.ts new file mode 100644 index 0000000..6dedeaa --- /dev/null +++ b/packages/app/src/views/register-view.ts @@ -0,0 +1,57 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import "../auth/login-form.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +@customElement("register-view") +class RegisterViewElement extends LitElement { + next: string = "/app"; + + static styles = [themeTokens, ...pageStyles]; + + connectedCallback(): void { + super.connectedCallback(); + const url = new URL(window.location.href); + const n = url.searchParams.get("next"); + if (n) this.next = n; + } + + render() { + return html` + + + Adventure Guide → + Create Account + + + + +
e.preventDefault()}> +
+ + + + + + +
+
+ `; + } +} + +export default RegisterViewElement; diff --git a/packages/app/src/views/trip-view.ts b/packages/app/src/views/trip-view.ts new file mode 100644 index 0000000..a367c01 --- /dev/null +++ b/packages/app/src/views/trip-view.ts @@ -0,0 +1,310 @@ +import { LitElement, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; +import "../components/card.ts"; + +interface Itinerary { + itineraryid: string; + tripid: string; + tripName: string; + owner?: string; + day: number; + date: string; + activities: Array<{ + time: string; + activity: string; + location: string; + description?: string; + pathId?: string; + poiId?: string; + campsiteId?: string; + }>; + campsiteId?: string; + campsiteName?: string; + notes?: string; + card: { + title: string; + description: string; + href: string; + }; +} + +@customElement("trip-view") +class TripViewElement extends LitElement { + slug: string = ""; + + @state() + itineraries: Itinerary[] = []; + + @state() + loading = true; + + @state() + error: string | null = null; + + @state() + user: any = null; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + static get observedAttributes() { + return ["slug"]; + } + + attributeChangedCallback( + name: string, + _old: string | null, + value: string | null + ) { + if (name === "slug" && value !== null) { + this.slug = value; + this.requestUpdate(); + if (this.user) { + this.loadItineraries(); + } + } + } + + connectedCallback() { + super.connectedCallback(); + + this._authObserver.observe(({ user }) => { + if (user) { + this.user = user; + // Load itineraries if slug is already set + if (this.slug) { + this.loadItineraries(); + } + } else { + this.user = null; + this.loading = false; + } + }); + } + + async loadItineraries() { + if (!this.slug) { + console.log("⚠️ No slug set, skipping itinerary load"); + return; + } + + try { + console.log("📅 Loading itineraries for trip:", this.slug); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + const response = await fetch(`/api/itineraries?trip=${this.slug}`, { + headers, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.itineraries = await response.json(); + console.log("✅ Loaded", this.itineraries.length, "itineraries"); + this.loading = false; + } catch (error) { + console.log("❌ Error loading itineraries:", error); + this.error = + error instanceof Error ? error.message : "Failed to load itineraries"; + this.loading = false; + } + } + + private handleDelete(itineraryid: string) { + const confirmed = window.confirm( + "Are you sure you want to delete this itinerary?" + ); + if (!confirmed) return; + + const message: [ + "itinerary/delete", + { + itineraryid: string; + callbacks?: { + onSuccess?: () => void; + onFailure?: (err: Error) => void; + }; + } + ] = [ + "itinerary/delete", + { + itineraryid, + callbacks: { + onSuccess: () => { + // Refresh list after deletion and show a quick toast + this.loadItineraries(); + const toast = document.createElement("div"); + toast.textContent = "Itinerary deleted"; + toast.style.position = "fixed"; + toast.style.bottom = "16px"; + toast.style.right = "16px"; + toast.style.background = "var(--color-success, #2e7d32)"; + toast.style.color = "white"; + toast.style.padding = "8px 12px"; + toast.style.borderRadius = "6px"; + toast.style.boxShadow = "0 2px 8px rgba(0,0,0,0.2)"; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2000); + }, + onFailure: (err: Error) => { + this.error = err.message || "Failed to delete itinerary"; + this.requestUpdate(); + }, + }, + }, + ]; + + this.dispatchEvent( + new CustomEvent("message", { + detail: message, + bubbles: true, + composed: true, + }) + ); + } + + static styles = [themeTokens, iconStyles, ...pageStyles]; + + render() { + const title = this.prettyTitle(this.slug); + const tripName = + this.itineraries.length > 0 ? this.itineraries[0].tripName : title; + + return html` + + + Adventure GuideTrips → + ${tripName} + + + + + ${this.renderItineraries()} + + +
  • + All Trips +
  • +
  • + Parks +
  • +
  • + Paths +
  • +
  • + Points of Interest +
  • + + `} + >
    + `; + } + + renderItineraries() { + if (this.loading) { + return html`
    Loading itinerary...
    `; + } + + if (this.error) { + return html`
    Error: ${this.error}
    `; + } + + if (this.itineraries.length === 0) { + return html` +

    + No itinerary found for this trip. Check back later for updates! +

    + `; + } + + return html` + + ${this.itineraries.map( + (itinerary) => html` + +

    Date: ${itinerary.date}

    + ${itinerary.campsiteId && itinerary.campsiteName + ? html`

    + Campsite: + ${itinerary.campsiteName} +

    ` + : ""} + +

    Activities:

    +
      + ${itinerary.activities.map((activity) => { + const locationContent = activity.pathId + ? html`${activity.location}` + : activity.poiId + ? html`${activity.location}` + : activity.campsiteId + ? html`${activity.location}` + : activity.location; + + return html` +
    • + ${activity.time} +
      + ${activity.activity} at + ${locationContent} + ${activity.description + ? html`
      ${activity.description}` + : ""} +
      +
    • + `; + })} +
    + + ${itinerary.notes + ? html`

    Notes: ${itinerary.notes}

    ` + : ""} + + `} + > + ${this.user?.username && itinerary.owner === this.user.username + ? html` +
    + +
    + ` + : ""} +
    + ` + )} +
    + `; + } + + prettyTitle(slug?: string) { + if (!slug) return "Trip"; + return slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + } +} + +export default TripViewElement; diff --git a/packages/app/src/views/trips-view.ts b/packages/app/src/views/trips-view.ts new file mode 100644 index 0000000..3ccd7a8 --- /dev/null +++ b/packages/app/src/views/trips-view.ts @@ -0,0 +1,299 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Observer, Auth } from "@calpoly/mustang"; +import { iconStyles } from "../styles/icon-styles.css.ts"; +import { themeTokens } from "../styles/theme-tokens.css.ts"; +import { pageStyles } from "../styles/page-styles.css.ts"; + +interface Itinerary { + itineraryid: string; + tripid: string; + tripName: string; + owner?: string; + day: number; + date: string; + activities: Array<{ + time: string; + activity: string; + location: string; + description?: string; + }>; + campsiteId?: string; + campsiteName?: string; + notes?: string; + card: { + title: string; + description: string; + href: string; + }; +} + +interface TripGroup { + tripid: string; + tripName: string; + itineraries: Itinerary[]; +} + +@customElement("trips-view") +class TripsViewElement extends LitElement { + @state() + itineraries: Itinerary[] = []; + + @state() + loading = true; + + @state() + error: string | null = null; + + @state() + user: any = null; + + _authObserver: Observer = new Observer(this, "natty:auth"); + + connectedCallback() { + super.connectedCallback(); + + this._authObserver.observe(({ user }) => { + if (user) { + this.user = user; + this.loadItineraries(); + } else { + this.user = null; + this.loading = false; + } + }); + } + + async loadItineraries() { + try { + console.log("📅 Loading itineraries"); + this.loading = true; + this.error = null; + + const headers = Auth.headers(this.user); + const response = await fetch("/api/itineraries", { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + this.itineraries = await response.json(); + console.log("✅ Loaded", this.itineraries.length, "itineraries"); + this.loading = false; + } catch (error) { + console.log("❌ Error loading itineraries:", error); + this.error = + error instanceof Error ? error.message : "Failed to load itineraries"; + this.loading = false; + } + } + + async deleteTrip(tripid: string) { + if (!this.user) return; + const confirmDelete = window.confirm( + `Delete trip ${tripid} and all its itineraries? This cannot be undone.` + ); + if (!confirmDelete) return; + + try { + const headers = Auth.headers(this.user); + // 1) Load all itineraries for this trip + const listRes = await fetch( + `/api/itineraries?trip=${encodeURIComponent(tripid)}`, + { headers } + ); + if (!listRes.ok) throw new Error(`List failed: HTTP ${listRes.status}`); + const list: Itinerary[] = await listRes.json(); + + // 2) Ownership check: ensure all belong to current user + const username = (this.user as any)?.username as string | undefined; + if (!username || !list.every((i) => i.owner === username)) { + this.error = "You can only delete your own trips."; + return; + } + + // 3) Delete each itinerary by id using existing endpoint + await Promise.all( + list.map((i) => + fetch(`/api/itineraries/${encodeURIComponent(i.itineraryid)}`, { + method: "DELETE", + headers, + }) + ) + ); + + // 4) Update local state + this.itineraries = this.itineraries.filter((i) => i.tripid !== tripid); + } catch (err) { + this.error = err instanceof Error ? err.message : String(err); + } + } + + groupByTrip(): TripGroup[] { + const groups = new Map(); + + this.itineraries.forEach((itinerary) => { + if (!groups.has(itinerary.tripid)) { + groups.set(itinerary.tripid, { + tripid: itinerary.tripid, + tripName: itinerary.tripName, + itineraries: [], + }); + } + groups.get(itinerary.tripid)!.itineraries.push(itinerary); + }); + + // Sort itineraries within each group by day + groups.forEach((group) => { + group.itineraries.sort((a, b) => a.day - b.day); + }); + + return Array.from(groups.values()); + } + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + } + + .loading-message, + .error-message, + .auth-message { + text-align: center; + padding: var(--spacing-lg); + margin: var(--spacing-md) 0; + } + + .auth-message { + background-color: var(--color-background-secondary); + border-radius: var(--radius-md); + } + + .login-link { + color: var(--color-link); + text-decoration: none; + font-weight: var(--font-weight-semibold); + } + + .login-link:hover { + text-decoration: underline; + } + `, + iconStyles, + ]; + + render() { + return html` + + Adventure GuideTrips + + + +

    + Browse available trips to national parks. Click on a trip to see the + detailed day-by-day itinerary. +

    + + ${this.user + ? html`

    Create a new itinerary

    ` + : ""} + ${this.renderContent()} + `; + } + + renderContent() { + if (this.loading) { + return html` +
    +

    Loading trips...

    +
    + `; + } + + if (this.error) { + if (this.error.includes("401")) { + return html` +
    +

    + Please to view + trips. +

    +
    + `; + } else { + return html` +
    +

    Error loading trips: ${this.error}

    +
    + `; + } + } + + if (this.itineraries.length === 0) { + return html` +
    +

    + Please to view + trips. +

    +
    + `; + } + + const tripGroups = this.groupByTrip(); + + return html` + + ${tripGroups.map((group) => { + const username = this.user?.username as string | undefined; + const ownedByUser = + !!username && group.itineraries.every((i) => i.owner === username); + const numDays = group.itineraries.length; + const startDate = group.itineraries[0]?.date || ""; + const endDate = + group.itineraries[group.itineraries.length - 1]?.date || ""; + const campsite = group.itineraries[0]?.campsiteName || ""; + + return html` + +
    + Open + ${ownedByUser + ? html`` + : ""} +
    +
    + `; + })} +
    + `; + } +} + +export default TripsViewElement; diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json new file mode 100644 index 0000000..db23bf6 --- /dev/null +++ b/packages/app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["*.ts", "/**/*.ts"] +} \ No newline at end of file diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts new file mode 100644 index 0000000..8b24c49 --- /dev/null +++ b/packages/app/vite.config.ts @@ -0,0 +1,75 @@ +import { defineConfig } from "vite"; +import { resolve } from "path"; +import fs from "node:fs/promises"; + +// Custom plugin to handle dynamic routing +const dynamicRoutingPlugin = () => { + return { + name: "dynamic-routing", + configureServer(server: any) { + // SPA fallback for /app and /app/* to index.html during development + server.middlewares.use("/app", async (_req: any, res: any, next: any) => { + try { + const indexPath = resolve(process.cwd(), "index.html"); + const html = await fs.readFile(indexPath, { encoding: "utf8" }); + res.setHeader("Content-Type", "text/html"); + res.end(html); + } catch (err) { + next(); + } + }); + + server.middlewares.use("/parks", (req: any, _res: any, next: any) => { + // Handle /parks/:parkid/index.html -> /parks/park/index.html + if (req.url?.match(/^\/parks\/[^\/]+\/index\.html$/)) { + req.url = "/parks/park/index.html"; + } + next(); + }); + + server.middlewares.use((req: any, _res: any, next: any) => { + // Handle /paths/:pathid.html -> /paths/path/index.html + if (req.url?.match(/^\/paths\/[^\/]+\.html$/)) { + req.url = "/paths/path/index.html"; + } + next(); + }); + + server.middlewares.use("/campsites", (req: any, _res: any, next: any) => { + // Handle /campsites/:siteid.html -> /campsites/site/index.html + if (req.url?.match(/^\/campsites\/[^\/]+\.html$/)) { + req.url = "/campsites/site/index.html"; + } + next(); + }); + + server.middlewares.use("/poi", (req: any, _res: any, next: any) => { + // Handle /poi/:poiid.html -> /poi/poi/index.html + if (req.url?.match(/^\/poi\/[^\/]+\.html$/)) { + req.url = "/poi/poi/index.html"; + } + next(); + }); + }, + }; +}; + +export default defineConfig({ + plugins: [dynamicRoutingPlugin()], + server: { + proxy: { + "/api": "http://localhost:3000", + "/auth": "http://localhost:3000", + "/images": "http://localhost:3000", + "/login": "http://localhost:3000", + "/register": "http://localhost:3000" + }, + // Handle dynamic routing in development + middlewareMode: false, + fs: { + strict: false, + }, + }, + // Configure as single-page application + appType: "spa", +}); diff --git a/packages/proto/public/styles/page.css b/packages/proto/public/styles/page.css index f102721..6f0ec8e 100644 --- a/packages/proto/public/styles/page.css +++ b/packages/proto/public/styles/page.css @@ -1,7 +1,7 @@ @import url('https://fonts.googleapis.com/css2?family=Knewave&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); -@import url('/styles/reset.css'); -@import url('/styles/tokens.css'); +@import url('./reset.css'); +@import url('./tokens.css'); html { scroll-behavior: smooth; diff --git a/packages/proto/trips/itinerary.html b/packages/proto/trips/itinerary.html index 16f1977..d504977 100644 --- a/packages/proto/trips/itinerary.html +++ b/packages/proto/trips/itinerary.html @@ -4,13 +4,13 @@ Trip Itineraries - - @@ -35,17 +35,17 @@

    @@ -53,16 +53,18 @@

    Yosemite Fall Colors - 4 Days

    diff --git a/packages/proto/trips/yellowstone-summer-itinerary.html b/packages/proto/trips/yellowstone-summer-itinerary.html index 2713892..36f56e6 100644 --- a/packages/proto/trips/yellowstone-summer-itinerary.html +++ b/packages/proto/trips/yellowstone-summer-itinerary.html @@ -4,13 +4,13 @@ Yellowstone Summer Adventure Itinerary - + @@ -41,23 +41,25 @@

    Where You'll Stay:

    -

    Trip visits Park: Yellowstone

    +

    + Trip visits Park: Yellowstone +

    Travel by Path: - Grand Loop Road + Grand Loop Road

    To Points of Interest: @@ -79,10 +81,10 @@

    Activities:

    Routes Used:

    diff --git a/packages/proto/trips/yellowstone-summer-trip.html b/packages/proto/trips/yellowstone-summer-trip.html index e7cc065..369a1c9 100644 --- a/packages/proto/trips/yellowstone-summer-trip.html +++ b/packages/proto/trips/yellowstone-summer-trip.html @@ -4,10 +4,10 @@ Yellowstone Summer Trip - +