From 499e5c4a802c919a67fb4860f56842ccf471efe2 Mon Sep 17 00:00:00 2001 From: Colter Purcell Date: Fri, 28 Nov 2025 11:27:33 -0600 Subject: [PATCH 1/9] Moving to new laptop --- lab13.md | 261 ++++++++ node_modules/.package-lock.json | 20 +- package-lock.json | 20 +- packages/app/index.html | 26 + packages/app/package.json | 25 + packages/app/public/assets/icons/camping.svg | 205 ++++++ .../images/paths/glacier-point-road.svg | 47 ++ .../app/public/images/paths/grand-loop.svg | 53 ++ .../app/public/images/paths/valley-loop.svg | 39 ++ packages/app/public/styles/page.css | 413 ++++++++++++ packages/app/public/styles/reset.css | 62 ++ packages/app/public/styles/tokens.css | 175 +++++ packages/app/scripts/theme.js | 59 ++ packages/app/src/auth/login-form.ts | 280 ++++++++ packages/app/src/components/card.ts | 344 ++++++++++ packages/app/src/components/nav.ts | 300 +++++++++ packages/app/src/components/parks-listing.ts | 176 ++++++ packages/app/src/components/paths-listing.ts | 176 ++++++ packages/app/src/components/poi-listing.ts | 176 ++++++ packages/app/src/components/section-header.ts | 143 +++++ packages/app/src/main.ts | 120 ++++ packages/app/src/pages/campsite-info.ts | 152 +++++ packages/app/src/pages/campsite-page.ts | 397 ++++++++++++ packages/app/src/pages/index.html | 21 + packages/app/src/pages/main-page.ts | 258 ++++++++ packages/app/src/pages/park-page.ts | 517 +++++++++++++++ packages/app/src/pages/path-page.ts | 338 ++++++++++ packages/app/src/pages/poi-page.ts | 336 ++++++++++ packages/app/src/styles/icon-styles.css.ts | 30 + packages/app/src/styles/page-styles.css.ts | 598 ++++++++++++++++++ packages/app/src/styles/theme-tokens.css.ts | 172 +++++ packages/app/src/views/camper-type-view.ts | 64 ++ packages/app/src/views/campers-view.ts | 67 ++ packages/app/src/views/home-view.ts | 138 ++++ packages/app/src/views/itinerary-view.ts | 329 ++++++++++ packages/app/src/views/login-view.ts | 52 ++ packages/app/src/views/parks-view.ts | 37 ++ packages/app/src/views/paths-view.ts | 37 ++ packages/app/src/views/poi-view.ts | 40 ++ packages/app/src/views/register-view.ts | 59 ++ packages/app/src/views/trip-view.ts | 312 +++++++++ packages/app/src/views/trips-view.ts | 232 +++++++ packages/app/styles/reset.css.ts | 68 ++ packages/app/tsconfig.json | 20 + packages/app/vite.config.ts | 75 +++ packages/proto/public/styles/page.css | 4 +- packages/proto/trips/itinerary.html | 30 +- .../trips/yellowstone-summer-itinerary.html | 22 +- .../proto/trips/yellowstone-summer-trip.html | 28 +- .../proto/trips/yosemite-fall-itinerary.html | 24 +- packages/proto/trips/yosemite-fall-trip.html | 28 +- packages/server/package.json | 7 +- packages/server/src/index.ts | 24 +- packages/server/src/models/itinerary-model.ts | 67 ++ packages/server/src/routes/itineraries.ts | 72 +++ packages/server/src/seed.ts | 260 +++++++- packages/server/src/services/itinerary-svc.ts | 42 ++ spa-migration-plan.md | 224 +++++++ 58 files changed, 8217 insertions(+), 84 deletions(-) create mode 100644 lab13.md create mode 100644 packages/app/index.html create mode 100644 packages/app/package.json create mode 100644 packages/app/public/assets/icons/camping.svg create mode 100644 packages/app/public/images/paths/glacier-point-road.svg create mode 100644 packages/app/public/images/paths/grand-loop.svg create mode 100644 packages/app/public/images/paths/valley-loop.svg create mode 100644 packages/app/public/styles/page.css create mode 100644 packages/app/public/styles/reset.css create mode 100644 packages/app/public/styles/tokens.css create mode 100644 packages/app/scripts/theme.js create mode 100644 packages/app/src/auth/login-form.ts create mode 100644 packages/app/src/components/card.ts create mode 100644 packages/app/src/components/nav.ts create mode 100644 packages/app/src/components/parks-listing.ts create mode 100644 packages/app/src/components/paths-listing.ts create mode 100644 packages/app/src/components/poi-listing.ts create mode 100644 packages/app/src/components/section-header.ts create mode 100644 packages/app/src/main.ts create mode 100644 packages/app/src/pages/campsite-info.ts create mode 100644 packages/app/src/pages/campsite-page.ts create mode 100644 packages/app/src/pages/index.html create mode 100644 packages/app/src/pages/main-page.ts create mode 100644 packages/app/src/pages/park-page.ts create mode 100644 packages/app/src/pages/path-page.ts create mode 100644 packages/app/src/pages/poi-page.ts create mode 100644 packages/app/src/styles/icon-styles.css.ts create mode 100644 packages/app/src/styles/page-styles.css.ts create mode 100644 packages/app/src/styles/theme-tokens.css.ts create mode 100644 packages/app/src/views/camper-type-view.ts create mode 100644 packages/app/src/views/campers-view.ts create mode 100644 packages/app/src/views/home-view.ts create mode 100644 packages/app/src/views/itinerary-view.ts create mode 100644 packages/app/src/views/login-view.ts create mode 100644 packages/app/src/views/parks-view.ts create mode 100644 packages/app/src/views/paths-view.ts create mode 100644 packages/app/src/views/poi-view.ts create mode 100644 packages/app/src/views/register-view.ts create mode 100644 packages/app/src/views/trip-view.ts create mode 100644 packages/app/src/views/trips-view.ts create mode 100644 packages/app/styles/reset.css.ts create mode 100644 packages/app/tsconfig.json create mode 100644 packages/app/vite.config.ts create mode 100644 packages/server/src/models/itinerary-model.ts create mode 100644 packages/server/src/routes/itineraries.ts create mode 100644 packages/server/src/services/itinerary-svc.ts create mode 100644 spa-migration-plan.md 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..f1a9f44 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -1495,7 +1495,6 @@ "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1636,6 +1635,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", @@ -4432,6 +4435,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.15", + "@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..aaccc9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -4381,6 +4384,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.15", + "@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/packages/app/index.html b/packages/app/index.html new file mode 100644 index 0000000..1a9b869 --- /dev/null +++ b/packages/app/index.html @@ -0,0 +1,26 @@ + + + + + + National Parks Adventure Guide + + + + + + + +
+ +
+
+
+ + diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 0000000..6adf4d5 --- /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.15", + "@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..8767566 --- /dev/null +++ b/packages/app/public/styles/page.css @@ -0,0 +1,413 @@ +@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; + padding: 0 var(--spacing-lg) var(--spacing-lg) var(--spacing-lg); +} + +/* 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 and section styling */ +article { + background-color: var(--color-background-section); + padding: var(--spacing-lg); + 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-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); +} \ 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..545c6d4 --- /dev/null +++ b/packages/app/src/auth/login-form.ts @@ -0,0 +1,280 @@ +// in proto/src/auth/login-form.ts +import { html, css, LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; + +interface LoginFormData { + username?: string; + password?: string; + confirmPassword?: string; +} + +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..e72f675 --- /dev/null +++ b/packages/app/src/components/card.ts @@ -0,0 +1,344 @@ +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 { + 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; + min-height: 200px; + 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(200px + 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: 200px; + object-fit: cover; + border-radius: var(--radius-md) var(--radius-md) 0 0; + } + + .card-content { + flex: 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; + } + + .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/nav.ts b/packages/app/src/components/nav.ts new file mode 100644 index 0000000..fa542ce --- /dev/null +++ b/packages/app/src/components/nav.ts @@ -0,0 +1,300 @@ +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..58bd80d --- /dev/null +++ b/packages/app/src/main.ts @@ -0,0 +1,120 @@ +import { Auth, define, History, Switch } from "@calpoly/mustang"; +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/campers-view.ts"; +import "./views/trips-view.ts"; +import "./views/poi-view.ts"; +import "./views/itinerary-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 "./views/camper-type-view.ts"; +import { LoginFormElement } from "./auth/login-form.ts"; + +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/campers", + view: () => html``, + }, + { + path: "/app/campers/:type", + view: (params: Switch.Params) => + html``, + }, + { + path: "/app/trips", + view: () => html``, + }, + { + path: "/app/trips/itinerary", + 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, + "login-form": LoginFormElement, +}); 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..844b19f --- /dev/null +++ b/packages/app/src/pages/index.html @@ -0,0 +1,21 @@ + + + + + + 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..7f8e661 --- /dev/null +++ b/packages/app/src/styles/page-styles.css.ts @@ -0,0 +1,598 @@ +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.20); + } + `, + 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; + padding: 0 var(--spacing-lg) var(--spacing-lg) var(--spacing-lg); + } + + /* 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); + padding: var(--spacing-lg); + 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-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); + } + `, +]; \ No newline at end of file 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/views/camper-type-view.ts b/packages/app/src/views/camper-type-view.ts new file mode 100644 index 0000000..02e86a3 --- /dev/null +++ b/packages/app/src/views/camper-type-view.ts @@ -0,0 +1,64 @@ +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("camper-type-view") +class CamperTypeViewElement extends LitElement { + type: string = ""; + + static get observedAttributes() { + return ["type"]; + } + + attributeChangedCallback( + name: string, + _old: string | null, + value: string | null + ) { + if (name === "type" && value !== null) { + this.type = value; + this.requestUpdate(); + } + } + + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + } + .muted { + color: var(--color-text-light); + } + `, + iconStyles, + ]; + + render() { + const title = this.prettyTitle(this.type); + return html` + + + Adventure Guide → + Camper Types → + ${title} + + + +

+ Placeholder content for ${title}. Replace with rich guidance, gear + lists, and safety tips. +

+ `; + } + + prettyTitle(slug?: string) { + if (!slug) return "Camper"; + return slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + } +} + +export default CamperTypeViewElement; diff --git a/packages/app/src/views/campers-view.ts b/packages/app/src/views/campers-view.ts new file mode 100644 index 0000000..a2102ad --- /dev/null +++ b/packages/app/src/views/campers-view.ts @@ -0,0 +1,67 @@ +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("campers-view") +class CampersViewElement extends LitElement { + static styles = [ + themeTokens, + ...pageStyles, + css` + :host { + display: block; + grid-column: 1 / -1; + } + `, + iconStyles, + ]; + + render() { + return html` + + Adventure Guide → + Campers + + + + + + + + + + + + + + + + + + + + + + + `; + } +} + +export default CampersViewElement; diff --git a/packages/app/src/views/home-view.ts b/packages/app/src/views/home-view.ts new file mode 100644 index 0000000..8e4ad0e --- /dev/null +++ b/packages/app/src/views/home-view.ts @@ -0,0 +1,138 @@ +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-view.ts b/packages/app/src/views/itinerary-view.ts new file mode 100644 index 0000000..1a6d9d7 --- /dev/null +++ b/packages/app/src/views/itinerary-view.ts @@ -0,0 +1,329 @@ +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; + }>; + 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; + } + } + + 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` + Activities: +
    + ${itinerary.activities.map( + (activity) => html` +
  • + ${activity.time} +
    + ${activity.activity} at + ${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..461386a --- /dev/null +++ b/packages/app/src/views/login-view.ts @@ -0,0 +1,52 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { LoginFormElement } from "../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; + +// Ensure custom element is defined for login-form (registered via define in main.ts) +void LoginFormElement; 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..df6a07e --- /dev/null +++ b/packages/app/src/views/register-view.ts @@ -0,0 +1,59 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { LoginFormElement } from "../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; + +void LoginFormElement; diff --git a/packages/app/src/views/trip-view.ts b/packages/app/src/views/trip-view.ts new file mode 100644 index 0000000..4f91675 --- /dev/null +++ b/packages/app/src/views/trip-view.ts @@ -0,0 +1,312 @@ +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; + }; +} + +@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; + } + } + + static styles = [ + themeTokens, + iconStyles, + ...pageStyles, + css` + :host { + display: block; + } + + .activity-list { + list-style: none; + padding: 0; + margin: var(--spacing-md) 0; + } + + .activity-item { + display: flex; + gap: var(--spacing-sm); + padding: var(--spacing-sm) 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; + flex-shrink: 0; + } + + .loading-message, + .error-message { + text-align: center; + padding: var(--spacing-lg); + } + + .quick-links { + padding: var(--spacing-lg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + } + + .quick-links h4 { + margin-top: 0; + } + + .quick-links ul { + list-style: none; + padding: 0; + } + + .quick-links li { + padding: var(--spacing-xs) 0; + } + + /* Override pageStyles for links to ensure they're styled */ + .quick-links 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; + } + + .quick-links a:hover { + color: var(--color-link-hover); + text-decoration-color: var(--color-link-hover); + } + + .quick-links a:visited { + color: var(--color-link-visited); + } + `, + ]; + + 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()} + + + `; + } + + 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` + ${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}

` + : ""} + + `}" + > +
+ ` + )} +
+ `; + } + + 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..345713e --- /dev/null +++ b/packages/app/src/views/trips-view.ts @@ -0,0 +1,232 @@ +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; + 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; + } + } + + 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.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 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` + + `; + })} + + `; + } +} + +export default TripsViewElement; diff --git a/packages/app/styles/reset.css.ts b/packages/app/styles/reset.css.ts new file mode 100644 index 0000000..727cb66 --- /dev/null +++ b/packages/app/styles/reset.css.ts @@ -0,0 +1,68 @@ +import { css } from "lit"; + +const styles = 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; + } +`; + +export default { styles }; 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 - +