Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9ccf0a741a | ||
db6c421b47 | |||
5f75a328b0 | |||
e4f4f9d23b | |||
562236acb3 | |||
![]() |
e25f5f1897 | ||
![]() |
5b0a51a6f4 | ||
![]() |
86c84b42e9 | ||
![]() |
21ff12f6b8 | ||
![]() |
595e984b12 | ||
![]() |
01f4f81651 |
28
README.md
28
README.md
|
@ -1,28 +1,22 @@
|
|||
# sv
|
||||
# Travel App
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
## How to Start
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
First, you need to clone this repository by clicking the triple dots button → `Open with VS Code` or using the command below:
|
||||
```
|
||||
git clone http://git.prototyping.id/20210782/travel-app.git
|
||||
```
|
||||
|
||||
Then, you need to download the project dependencies by typing this command:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
Once you've created a project and installed dependencies, start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
@ -35,4 +29,4 @@ npm run build
|
|||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
1189
package-lock.json
generated
1189
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
|
@ -15,9 +15,20 @@
|
|||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@types/topojson-server": "^3.0.4",
|
||||
"nodemon": "^3.1.10",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@googlemaps/js-api-loader": "^1.16.8",
|
||||
"d3": "^7.9.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"topojson-server": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
|
40
src/app.css
Normal file
40
src/app.css
Normal file
|
@ -0,0 +1,40 @@
|
|||
:root {
|
||||
--white: #FFFFFF;
|
||||
--black: #000000;
|
||||
|
||||
/* Gray */
|
||||
--gray-50: #F0F0F0;
|
||||
--gray-100: #E6E6E6;
|
||||
--gray-200: #C6C6C6;
|
||||
--gray-300: #B0B0B0;
|
||||
--gray-400: #9B9B9B;
|
||||
--gray-500: #868686;
|
||||
--gray-600: #727272;
|
||||
--gray-700: #5E5E5E;
|
||||
--gray-800: #4B4B4B;
|
||||
--gray-900: #393939;
|
||||
|
||||
/* Planner */
|
||||
--planner-50: #A5FFFF;
|
||||
--planner-100: #83EDFA;
|
||||
--planner-200: #60D7E5;
|
||||
--planner-300: #38C1D0;
|
||||
--planner-400: #00ACBB;
|
||||
--planner-500: #0096A6;
|
||||
--planner-600: #008190;
|
||||
--planner-700: #006C7B;
|
||||
--planner-800: #005865;
|
||||
--planner-900: #004450;
|
||||
|
||||
/* Memory */
|
||||
--memory-50: #FFCFAD;
|
||||
--memory-100: #FFB491;
|
||||
--memory-200: #FF9976;
|
||||
--memory-300: #FF7F5E;
|
||||
--memory-400: #FF6747;
|
||||
--memory-500: #EE4F33;
|
||||
--memory-600: #D43721;
|
||||
--memory-700: #B82010;
|
||||
--memory-800: #9B0502;
|
||||
--memory-900: #7D0000;
|
||||
}
|
|
@ -2,11 +2,11 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css' integrity='sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==' crossorigin='anonymous'/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
191
src/lib/components/AddPlaces.svelte
Normal file
191
src/lib/components/AddPlaces.svelte
Normal file
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Loader } from '@googlemaps/js-api-loader';
|
||||
|
||||
export let onPlaceSelected: (place: google.maps.places.PlaceResult) => void;
|
||||
export let countryRestriction: string | undefined = undefined;
|
||||
export let placeTypes: string[] = ['establishment'];
|
||||
export let placeholder = 'Add a place';
|
||||
export let id = 'add-places';
|
||||
|
||||
let inputContainer: HTMLDivElement;
|
||||
let inputWrapper: HTMLDivElement;
|
||||
let showAddButton = false;
|
||||
let lastSelectedPlaceName = '';
|
||||
let selectedPlace: google.maps.places.PlaceResult | null = null;
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY;
|
||||
|
||||
onMount(async () => {
|
||||
if (!GOOGLE_PLACES_API_KEY) {
|
||||
console.error('Google Maps API key is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = new Loader({
|
||||
apiKey: GOOGLE_PLACES_API_KEY,
|
||||
version: "weekly",
|
||||
libraries: ["places"],
|
||||
language: 'en'
|
||||
});
|
||||
|
||||
try {
|
||||
await loader.importLibrary("places");
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = id;
|
||||
input.placeholder = placeholder;
|
||||
|
||||
inputWrapper.appendChild(input);
|
||||
inputElement = input;
|
||||
|
||||
const autocompleteOptions: google.maps.places.AutocompleteOptions = {
|
||||
types: placeTypes
|
||||
};
|
||||
|
||||
if (countryRestriction) {
|
||||
autocompleteOptions.componentRestrictions = { country: countryRestriction.toLowerCase() };
|
||||
}
|
||||
|
||||
const autocomplete = new google.maps.places.Autocomplete(input, autocompleteOptions);
|
||||
autocomplete.setFields(['name', 'formatted_address', 'photos', 'place_id']);
|
||||
// TODO: how to get the photos?
|
||||
|
||||
autocomplete.addListener('place_changed', () => {
|
||||
const place = autocomplete.getPlace();
|
||||
if (place && place.name) {
|
||||
selectedPlace = place;
|
||||
lastSelectedPlaceName = input.value.trim();
|
||||
showAddButton = true;
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
const trimmed = input.value.trim();
|
||||
if (trimmed === lastSelectedPlaceName) {
|
||||
return;
|
||||
} else {
|
||||
showAddButton = false;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading Places Autocomplete:', error);
|
||||
}
|
||||
});
|
||||
|
||||
function handleAddPlace() {
|
||||
if (selectedPlace && inputElement) {
|
||||
onPlaceSelected(selectedPlace);
|
||||
|
||||
inputElement.value = ''; // reset the value
|
||||
|
||||
showAddButton = false;
|
||||
lastSelectedPlaceName = '';
|
||||
selectedPlace = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="add-places-wrapper">
|
||||
<div class="input-container {showAddButton ? 'with-button' : ''}" bind:this={inputContainer}>
|
||||
<div class="input-with-icon" bind:this={inputWrapper}>
|
||||
<i class="fa-solid fa-location-dot location-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
{#if showAddButton}
|
||||
<button class="add-button" onclick={handleAddPlace} aria-label="Add a new place">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.add-places-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1 1 100%;
|
||||
transition: flex-basis 0.3s ease;
|
||||
}
|
||||
|
||||
.input-container.with-button {
|
||||
flex: 1 1 auto;
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.input-with-icon {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--gray-400);
|
||||
font-size: 1rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
padding: 0.55rem 0.75rem;
|
||||
color: var(--gray-200);
|
||||
background: none;
|
||||
font-size: 1.2rem;
|
||||
border: none;
|
||||
border-radius: 2rem;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
background-color: var(--gray-50);
|
||||
color: var(--planner-400);
|
||||
}
|
||||
|
||||
:global(input#add-places) {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.75rem 0.75rem 0.75rem 2.5rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--gray-50);
|
||||
border: 2px solid var(--gray-50);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--gray-600);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(input#add-places:hover) {
|
||||
background-color: var(--gray-100);
|
||||
border-color: var(--gray-100);
|
||||
}
|
||||
|
||||
:global(input#add-places:focus) {
|
||||
outline-color: var(--planner-400);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
:global(.pac-container) {
|
||||
border-radius: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid var(--gray-100);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
:global(.pac-item) {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.pac-item:hover) {
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
</style>
|
48
src/lib/components/BottomBar.svelte
Normal file
48
src/lib/components/BottomBar.svelte
Normal file
|
@ -0,0 +1,48 @@
|
|||
<script>
|
||||
export let title = 'Your Trips';
|
||||
export let desc = 'Click to view all your trips';
|
||||
export let onClick = () => {};
|
||||
</script>
|
||||
|
||||
<div class="bottom-bar">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="text" onclick={onClick}>
|
||||
<h2>{title}</h2>
|
||||
<p class="hint">{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bottom-bar {
|
||||
display: flex;
|
||||
bottom: 0;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
background-color: var(--white);
|
||||
border-radius: 20px 20px 0 0;
|
||||
box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.text {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.text:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.text h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0.2rem 0 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
</style>
|
76
src/lib/components/Button.svelte
Normal file
76
src/lib/components/Button.svelte
Normal file
|
@ -0,0 +1,76 @@
|
|||
<script>
|
||||
import '../../app.css';
|
||||
export let text = 'Button';
|
||||
export let type = 'single';
|
||||
export let onClick = () => {};
|
||||
</script>
|
||||
|
||||
<button class="{type}" onclick={onClick}>
|
||||
{text}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button {
|
||||
border: none;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.single {
|
||||
background-color: var(--planner-300);
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.single:hover {
|
||||
opacity: 0.75;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.blue {
|
||||
width: 50%;
|
||||
background-color: var(--planner-400);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.blue:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.gray {
|
||||
width: 50%;
|
||||
background: var(--gray-50);
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.gray:hover {
|
||||
background-color: var(--gray-100);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.memory {
|
||||
background-color: var(--memory-500);
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.memory:hover {
|
||||
background-color: var(--memory-400);
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.orange {
|
||||
width: 50%;
|
||||
background-color: var(--memory-500);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.orange:hover {
|
||||
background-color: var(--memory-400);
|
||||
}
|
||||
</style>
|
104
src/lib/components/ItineraryDate.svelte
Normal file
104
src/lib/components/ItineraryDate.svelte
Normal file
|
@ -0,0 +1,104 @@
|
|||
<script lang="ts">
|
||||
import PlaceCard from "./PlaceCard.svelte";
|
||||
import AddPlaces from "./AddPlaces.svelte";
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
export let date;
|
||||
export let isExpanded = true;
|
||||
export let places: { name: string; desc?: string; img?: string; time?: string; }[] = [];
|
||||
|
||||
function toggleDate() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function handlePlaceSelected(place: google.maps.places.PlaceResult) {
|
||||
const newPlace = {
|
||||
name: place.name || 'Unknown Place',
|
||||
desc: place.formatted_address || '',
|
||||
img: place.photos ? place.photos[0].getUrl() : 'placeholder.jpeg',
|
||||
time: 'Add Time'
|
||||
};
|
||||
|
||||
places = [...places, newPlace];
|
||||
}
|
||||
|
||||
function handleDeletePlace(index: number) {
|
||||
places = places.filter((_, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="date-section">
|
||||
<button class="date-header" onclick={toggleDate}>
|
||||
<div class="date-text">
|
||||
<i class="fa-solid fa-chevron-right arrow-icon" class:rotated={isExpanded}></i>
|
||||
<h3>{date}</h3>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div
|
||||
class="date-content"
|
||||
transition:slide={{ duration: 400, easing: quintOut }}
|
||||
>
|
||||
{#each places as place, i}
|
||||
<PlaceCard {place} onDelete={() => handleDeletePlace(i)} />
|
||||
{/each}
|
||||
|
||||
<div class="add-places-container">
|
||||
<AddPlaces
|
||||
onPlaceSelected={handlePlaceSelected}
|
||||
countryRestriction="tw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.date-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.date-text h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
transition: transform 0.3s ease;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.date-content {
|
||||
padding: 1rem 0 1rem 2rem;
|
||||
}
|
||||
|
||||
.add-places-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
150
src/lib/components/Nav.svelte
Normal file
150
src/lib/components/Nav.svelte
Normal file
|
@ -0,0 +1,150 @@
|
|||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let title = "Travel App";
|
||||
export let activeTab = "Home";
|
||||
export let darkMode = false;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param tab {string}
|
||||
*/
|
||||
function handleNavigation(tab) {
|
||||
activeTab = tab;
|
||||
if (tab === 'Home') {
|
||||
goto('/');
|
||||
} else if (tab === 'Planner') {
|
||||
goto('/trips');
|
||||
} else if (tab === 'Memory') {
|
||||
goto('/memories');
|
||||
} else {
|
||||
console.log("will be implemented later");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class:dark-mode={darkMode}>
|
||||
<div class="logo">{title}</div>
|
||||
<div class="right-nav">
|
||||
<div class="menu">
|
||||
<button
|
||||
class:active={activeTab === "Home"}
|
||||
onclick={() => handleNavigation("Home")}>
|
||||
Home
|
||||
</button>
|
||||
<button
|
||||
class:active={activeTab === "Planner"}
|
||||
onclick={() => handleNavigation("Planner")}>
|
||||
Planner
|
||||
</button>
|
||||
<button
|
||||
class:active={activeTab === "Memory"}
|
||||
onclick={() => handleNavigation("Memory")}>
|
||||
Memory
|
||||
</button>
|
||||
</div>
|
||||
<div class="profile">
|
||||
<button class="profile-btn" aria-label="Open profile">
|
||||
<i class="fa-regular fa-user fa-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.right-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.menu button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--gray-400);
|
||||
transition: all 0.2s ease;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu button.active {
|
||||
color: var(--black);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu button:hover {
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.profile-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
opacity: 0.3;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.profile-btn:hover {
|
||||
background-color: var(--gray-100);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
nav.dark-mode {
|
||||
background-color: var(--black);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
nav.dark-mode .menu button {
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
nav.dark-mode .menu button:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
nav.dark-mode .menu button.active {
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
nav.dark-mode .profile-btn {
|
||||
background-color: var(--black);
|
||||
}
|
||||
|
||||
nav.dark-mode .profile-btn:hover {
|
||||
background-color: var(--gray-700);
|
||||
}
|
||||
|
||||
nav .profile-btn i {
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
nav.dark-mode .profile-btn i {
|
||||
color: var(--white);
|
||||
}
|
||||
</style>
|
343
src/lib/components/NewMemoryPopup.svelte
Normal file
343
src/lib/components/NewMemoryPopup.svelte
Normal file
|
@ -0,0 +1,343 @@
|
|||
<script lang="ts">
|
||||
import '../../app.css';
|
||||
import Button from './Button.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let showPopup = false;
|
||||
export let locations = [];
|
||||
export let onAddMemory = () => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
let selectedLocation = '';
|
||||
let customLocation = '';
|
||||
let images = [];
|
||||
let dragActive = false;
|
||||
|
||||
let startDate = '';
|
||||
let endDate = '';
|
||||
|
||||
function handleFiles(files) {
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
images = [...images, file];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
dragActive = false;
|
||||
handleFiles(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
dragActive = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(event) {
|
||||
event.preventDefault();
|
||||
dragActive = false;
|
||||
}
|
||||
|
||||
function handleInputChange(event) {
|
||||
if (event.target.files) {
|
||||
handleFiles(event.target.files);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelClick() {
|
||||
onCancel();
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
showPopup = false;
|
||||
selectedLocation = '';
|
||||
customLocation = '';
|
||||
images = [];
|
||||
startDate = '';
|
||||
endDate = '';
|
||||
showLocationError = false;
|
||||
showImageError = false;
|
||||
}
|
||||
|
||||
function isCustomLocation() {
|
||||
return selectedLocation === 'custom';
|
||||
}
|
||||
|
||||
let showLocationError = false;
|
||||
let showImageError = false;
|
||||
|
||||
function handleAddMemory() {
|
||||
showLocationError = selectedLocation === '' || (selectedLocation === 'custom' && customLocation.trim() === '');
|
||||
showImageError = images.length === 0;
|
||||
|
||||
if (showLocationError || showImageError) return;
|
||||
|
||||
const finalLocation = selectedLocation === 'custom' ? customLocation : selectedLocation;
|
||||
onAddMemory({ location: finalLocation, images, startDate, endDate });
|
||||
reset();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showPopup}
|
||||
<div class="overlay">
|
||||
<div class="popup {showLocationError || showImageError ? 'error' : ''}">
|
||||
<h1>Add new memory</h1>
|
||||
|
||||
<div class="input-form">
|
||||
<label for="location">Load from past trips</label>
|
||||
<select id="location" bind:value={selectedLocation}>
|
||||
<option value="" disabled>Select location</option>
|
||||
{#each locations as loc}
|
||||
<option value={loc}>{loc}</option>
|
||||
{/each}
|
||||
<option value="custom" class="custom-option">+ Enter custom trip</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if isCustomLocation()}
|
||||
<div class="input-form">
|
||||
<label for="custom-location">Custom Location</label>
|
||||
<input
|
||||
type="text"
|
||||
id="custom-location"
|
||||
bind:value={customLocation}
|
||||
placeholder="Type your location"
|
||||
/>
|
||||
{#if showLocationError}
|
||||
<p class="error-message">Please enter a location.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="date-group">
|
||||
<div class="input-form">
|
||||
<label for="start-date">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
bind:value={startDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-form">
|
||||
<label for="end-date">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
bind:value={endDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if showLocationError}
|
||||
<p class="error-message">Please enter a location.</p>
|
||||
{/if}
|
||||
|
||||
<div class="input-form">
|
||||
<label>Upload images</label>
|
||||
<div class="drop-area {dragActive ? 'active' : ''}"
|
||||
on:drop={handleDrop}
|
||||
on:dragover={handleDragOver}
|
||||
on:dragleave={handleDragLeave}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
on:change={handleInputChange}
|
||||
style="display: none;"
|
||||
id="fileInput"
|
||||
/>
|
||||
<div class="drop-label" on:click={() => document.getElementById('fileInput')?.click()}>
|
||||
{#if images.length === 0}
|
||||
<span>Drop image here</span>
|
||||
{:else}
|
||||
<div class="preview-list">
|
||||
{#each images as img}
|
||||
<div class="preview-item">
|
||||
<img src={URL.createObjectURL(img)} alt={img.name} />
|
||||
<p>{img.name}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showImageError}
|
||||
<p class="error-message">Please upload at least one image.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<Button text="Cancel" type="gray" onClick={handleCancelClick} />
|
||||
<Button text="Add a new memory" type="orange"
|
||||
onClick={handleAddMemory}
|
||||
disabled={showLocationError || showImageError}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<style>
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup {
|
||||
background: var(--gray-900);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
width: 80%;
|
||||
max-width: 560px;
|
||||
box-shadow: 0 0px 20px rgba(0, 0, 0, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.popup.error {
|
||||
border: 2px solid var(--memory-500);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--memory-500);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.popup h1 {
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.input-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.input-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--gray-200);
|
||||
}
|
||||
|
||||
.input-form input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--gray-900);
|
||||
color: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-form select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.5rem 0.75rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--gray-900);
|
||||
color: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
outline: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: var(--memory-500);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.date-group .input-form {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
width: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-item img {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-item p {
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
margin-top: 0.3rem;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.drop-area {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
background: var(--gray-900);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--white);
|
||||
transition: border-color 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-area.active {
|
||||
border-color: var(--memory-500);
|
||||
color: var(--memory-500);
|
||||
}
|
||||
|
||||
.drop-label {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
option.custom-option {
|
||||
color: var(--memory-500);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
256
src/lib/components/NewTripPopup.svelte
Normal file
256
src/lib/components/NewTripPopup.svelte
Normal file
|
@ -0,0 +1,256 @@
|
|||
<script lang="ts">
|
||||
import { Colors } from '$lib/constants/Colors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { Loader } from '@googlemaps/js-api-loader';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
export let showPopup = false;
|
||||
export let fromPage = 'home';
|
||||
|
||||
let destination = "";
|
||||
let startDate = "";
|
||||
let endDate = "";
|
||||
let friends = "";
|
||||
let destinationError = false;
|
||||
let startDateError = false;
|
||||
let endDateError = false;
|
||||
let destinationInput: HTMLDivElement;
|
||||
|
||||
const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY;
|
||||
|
||||
onMount(async () => {
|
||||
if (!GOOGLE_PLACES_API_KEY) {
|
||||
console.error('Google Maps API key is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = new Loader({
|
||||
apiKey: GOOGLE_PLACES_API_KEY,
|
||||
version: "weekly",
|
||||
libraries: ["places"],
|
||||
language: 'en'
|
||||
});
|
||||
|
||||
try {
|
||||
await loader.importLibrary("places");
|
||||
|
||||
const waitForElement = () => new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if (destinationInput) return resolve();
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
|
||||
await waitForElement();
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = 'destination-input';
|
||||
input.placeholder = 'Where do you want to go?';
|
||||
|
||||
destinationInput.appendChild(input);
|
||||
|
||||
const autocomplete = new google.maps.places.Autocomplete(input, {
|
||||
types: ['(regions)']
|
||||
});
|
||||
autocomplete.setFields(['name', 'formatted_address']);
|
||||
|
||||
autocomplete.addListener('place_changed', () => {
|
||||
const place = autocomplete.getPlace();
|
||||
destination = place.name || "";
|
||||
});
|
||||
|
||||
|
||||
// ------ The Implementation below is the new one, but can't style it --------
|
||||
// const placeAutocomplete = new google.maps.places.PlaceAutocompleteElement({
|
||||
// types: ['(cities)'], // Restrict to cities only
|
||||
// });
|
||||
|
||||
// destinationInput.appendChild(placeAutocomplete);
|
||||
|
||||
// //@ts-ignore
|
||||
// placeAutocomplete.addEventListener('gmp-select', async ({ placePrediction }) => {
|
||||
// const place = placePrediction.toPlace();
|
||||
// await place.fetchFields({ fields: ['displayName', 'formattedAddress', 'location'] });
|
||||
// destination = place.displayName;
|
||||
// });
|
||||
} catch (error) {
|
||||
console.error('Error loading Places Autocomplete:', error);
|
||||
}
|
||||
});
|
||||
|
||||
function handleCancel() {
|
||||
showPopup = false;
|
||||
destination = "";
|
||||
startDate = "";
|
||||
endDate = "";
|
||||
friends = "";
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
destinationError = !destination;
|
||||
startDateError = !startDate;
|
||||
endDateError = !endDate;
|
||||
|
||||
if (destinationError || startDateError || endDateError) {
|
||||
alert('Please fill in all required fields: Destination, Start Date, End Date');
|
||||
return;
|
||||
}
|
||||
|
||||
goto(`/itinerary?from=${fromPage}`);
|
||||
handleCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showPopup}
|
||||
<div class="overlay">
|
||||
<div class="popup">
|
||||
<h1>Start a New Plan</h1>
|
||||
|
||||
<div class="input-form">
|
||||
<label for="destination-input" class:error={destinationError}>Destination</label>
|
||||
<div bind:this={destinationInput} class="destination-wrapper" id="destination"></div>
|
||||
</div>
|
||||
|
||||
<div class="date-group">
|
||||
<div class="input-form">
|
||||
<label for="start-date" class:error={startDateError}>Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
bind:value={startDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-form">
|
||||
<label for="end-date" class:error={endDateError}>End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
bind:value={endDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-form">
|
||||
<label for="trip-friends">
|
||||
<span class="invite-label">
|
||||
+ Invite Friends
|
||||
<i class="fa-solid fa-user-group" style="color: {Colors.gray.dark800}"></i>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="trip-friends"
|
||||
bind:value={friends}
|
||||
placeholder="Enter email addresses"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<Button text="Cancel" type="gray" onClick={handleCancel} />
|
||||
<Button text="Start" type="blue" onClick={handleStart} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.popup {
|
||||
background: var(--white);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
width: 80%;
|
||||
max-width: 560px;
|
||||
box-shadow: 0 0px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.popup h1 {
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.input-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.input-form label.error {
|
||||
color: var(--memory-600) !important;
|
||||
}
|
||||
|
||||
.input-form input {
|
||||
width: 95.3%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline-color: var(--planner-600);
|
||||
}
|
||||
|
||||
:global(input#destination-input) {
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
width: 95.3%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
:global(input#destination-input:focus) {
|
||||
outline-color: var(--planner-600);
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.date-group .input-form {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.date-group .input-form input {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.invite-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
252
src/lib/components/PlaceCard.svelte
Normal file
252
src/lib/components/PlaceCard.svelte
Normal file
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Place = {
|
||||
name: string;
|
||||
desc?: string;
|
||||
image: string;
|
||||
time?: string;
|
||||
};
|
||||
|
||||
const defaultPlace: Omit<Place, 'desc'> = {
|
||||
name: 'PlaceName',
|
||||
image: 'placeholder.jpeg',
|
||||
time: 'Add Time'
|
||||
};
|
||||
|
||||
export let place: Partial<Place> = {};
|
||||
export let variant: 'simple' | 'detailed' = 'detailed';
|
||||
export let onDelete: () => void = () => {};
|
||||
|
||||
let showDelete = false;
|
||||
|
||||
function toggleDelete(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
showDelete = true;
|
||||
}
|
||||
|
||||
function handleDelete(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
showDelete = false;
|
||||
onDelete();
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
showDelete = false;
|
||||
}
|
||||
|
||||
// Close delete option when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.actions')) {
|
||||
showDelete = false;
|
||||
}
|
||||
}
|
||||
|
||||
// merge user-provided values with defaults
|
||||
$: fullPlace = { ...defaultPlace, ...place };
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside}/>
|
||||
|
||||
{#if variant === 'simple'}
|
||||
<div class="place-card simple">
|
||||
<img class="place-image simple" src={fullPlace.image} alt=""/>
|
||||
<div class="place-name simple">{fullPlace.name}</div>
|
||||
<div class="actions" role="group" onmouseleave={handleMouseLeave}>
|
||||
{#if showDelete}
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={handleDelete}
|
||||
aria-label="Delete place"
|
||||
transition:fade={{ duration: 100 }}
|
||||
>
|
||||
<span class="delete-text">Delete place</span>
|
||||
<i class="fa-regular fa-trash-can"></i>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="more-btn" onclick={toggleDelete} aria-label="More options">
|
||||
<i class="fa-solid fa-ellipsis-vertical"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="place-card detailed">
|
||||
<img class="place-image detailed" src={fullPlace.image} alt=""/>
|
||||
<div class="place-details" role="group">
|
||||
<div class="actions-detailed">
|
||||
<button class="close-btn" onclick={onDelete} aria-label="Delete place">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="place-name">{fullPlace.name}</div>
|
||||
{#if fullPlace.desc}
|
||||
<p class="place-desc">{fullPlace.desc}</p>
|
||||
{/if}
|
||||
<div class="plan-time">
|
||||
<button class="edit-time">{fullPlace.time}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.place-card {
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.place-image {
|
||||
object-fit: cover;
|
||||
border: solid 1px var(--gray-100);
|
||||
}
|
||||
|
||||
/* Simple variant styles */
|
||||
.place-card.simple {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.place-image.simple {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.place-name.simple {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem 0.85rem;
|
||||
color: var(--gray-400);
|
||||
cursor: pointer;
|
||||
border-radius: 2rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.more-btn:hover {
|
||||
background-color: var(--gray-50);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: rgba(240, 240, 240, 0.8);
|
||||
border: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: var(--gray-400);
|
||||
cursor: pointer;
|
||||
border-radius: 2rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: var(--gray-100);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--gray-50);
|
||||
border: none;
|
||||
padding: 0.5rem 0.85rem;
|
||||
color: var(--gray-600);
|
||||
cursor: pointer;
|
||||
border-radius: 2rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: var(--memory-50);
|
||||
color: var(--memory-600);
|
||||
}
|
||||
|
||||
.delete-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.place-card.detailed {
|
||||
display: flex;
|
||||
padding: 0.5rem 0;
|
||||
gap: 3%;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.place-image.detailed {
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.place-details {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--gray-50);
|
||||
flex: 1;
|
||||
border-radius: 20px;
|
||||
box-sizing: border-box;
|
||||
padding: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.actions-detailed {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.place-details:hover .actions-detailed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.place-name {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.place-desc {
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
color: var(--gray-400);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.edit-time {
|
||||
background-color: var(--gray-200);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 12px;
|
||||
color: var(--gray-800);
|
||||
font-size: 0.7rem;
|
||||
margin-top: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-time:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
32
src/lib/components/ProfilePicture.svelte
Normal file
32
src/lib/components/ProfilePicture.svelte
Normal file
|
@ -0,0 +1,32 @@
|
|||
<script>
|
||||
export let image = '';
|
||||
export let marginLeft = '0px';
|
||||
export let zIndex = '0';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="profile-picture"
|
||||
style="margin-left: {marginLeft}; z-index: {zIndex}"
|
||||
>
|
||||
{#if image}
|
||||
<img class="profile-img" src={image} alt="" />
|
||||
{:else}
|
||||
<img class="profile-img" src='profile-pic.png' alt="" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-picture {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.profile-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
69
src/lib/components/TripCard.svelte
Normal file
69
src/lib/components/TripCard.svelte
Normal file
|
@ -0,0 +1,69 @@
|
|||
<script>
|
||||
export let destination = '';
|
||||
export let startDate = '';
|
||||
export let endDate = '';
|
||||
export let image = '';
|
||||
</script>
|
||||
|
||||
<div class="trip-card">
|
||||
<div class="image" style="background-image: url({image})">
|
||||
<!-- Image placeholder if no image provided -->
|
||||
{#if !image}
|
||||
<div class="placeholder">
|
||||
<i class="fa-solid fa-image" style="color: var(--gray-400)"></i>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="info">
|
||||
<h3>{destination}</h3>
|
||||
<p class="date">{startDate} - {endDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.trip-card {
|
||||
background: var(--white);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.trip-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 160px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--gray-100);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.info h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
</style>
|
131
src/lib/components/WorldMap.svelte
Normal file
131
src/lib/components/WorldMap.svelte
Normal file
|
@ -0,0 +1,131 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as d3 from 'd3';
|
||||
import { feature } from 'topojson-client';
|
||||
import { Colors } from '../constants/Colors';
|
||||
import '../../app.css';
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
const width = mapContainer.clientWidth;
|
||||
const height = mapContainer.clientHeight;
|
||||
|
||||
// Create SVG
|
||||
const svg = d3.select(mapContainer)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%')
|
||||
.attr('viewBox', `0 0 ${width} ${height}`) // make a coordinate from (0, 0) to (width, height)
|
||||
.attr('preserveAspectRatio', 'xMidYMid meet') as d3.Selection<SVGSVGElement, unknown, null, undefined>; // center the map
|
||||
|
||||
// Add a group for all map elements that will be transformed
|
||||
const g = svg.append('g');
|
||||
|
||||
// Create projection
|
||||
const projection = d3.geoMercator()
|
||||
.scale(width / (2 * Math.PI))
|
||||
.translate([width / 2, height / 1.6]); // position the map, horizontally centered but is slighty upward
|
||||
|
||||
const path = d3.geoPath().projection(projection);
|
||||
|
||||
// Tokyo coordinates [longitude, latitude]
|
||||
const tokyo: [number, number] = [139.6917, 35.6895];
|
||||
|
||||
const initMap = async () => {
|
||||
try {
|
||||
// Load world map data
|
||||
const response = await fetch('https://unpkg.com/world-atlas@2/countries-110m.json');
|
||||
const world = await response.json();
|
||||
|
||||
// Convert TopoJSON to GeoJSON
|
||||
const countries = feature(world, world.objects.countries) as any;
|
||||
|
||||
// Draw the map
|
||||
g.append('g')
|
||||
.selectAll('path')
|
||||
.data(countries.features)
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('d', path as any)
|
||||
.attr('class', 'country')
|
||||
.attr('fill', Colors.gray.light200)
|
||||
.attr('stroke', Colors.gray.light50)
|
||||
.attr('stroke-width', '0.5');
|
||||
|
||||
// Add Tokyo marker
|
||||
g.append('circle')
|
||||
.attr('cx', projection(tokyo)![0])
|
||||
.attr('cy', projection(tokyo)![1])
|
||||
.attr('r', 5)
|
||||
.attr('class', 'marker')
|
||||
.attr('fill', Colors.planner.med400);
|
||||
|
||||
// Add zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([1, 8])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom)
|
||||
.call(zoom.transform, d3.zoomIdentity);
|
||||
} catch (error) {
|
||||
console.error('Error loading map:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initMap();
|
||||
|
||||
return () => {
|
||||
d3.select(mapContainer).selectAll('*').remove();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={mapContainer} class="map-wrapper"></div>
|
||||
|
||||
<style>
|
||||
.map-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--gray-50);
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
:global(.country) {
|
||||
transition: fill 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.country:hover) {
|
||||
fill: #a1cdd2;
|
||||
}
|
||||
|
||||
:global(.marker) {
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
:global(.marker:hover) {
|
||||
r: 8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.dark .map-wrapper) {
|
||||
background-color: var(--black);
|
||||
}
|
||||
|
||||
:global(.dark .country) {
|
||||
fill: #121212;
|
||||
stroke: var(--gray-700);
|
||||
}
|
||||
|
||||
:global(.dark .country:hover) {
|
||||
fill: #5a1c05;
|
||||
}
|
||||
|
||||
:global(.dark .marker) {
|
||||
fill: var(--memory-500);
|
||||
}
|
||||
</style>
|
40
src/lib/constants/Colors.ts
Normal file
40
src/lib/constants/Colors.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
export const Colors = {
|
||||
white: '#FFFFFF',
|
||||
black: '#121212',
|
||||
gray: {
|
||||
light50: '#F0F0F0',
|
||||
light100: '#E6E6E6',
|
||||
light200: '#C6C6C6',
|
||||
light300: '#B0B0B0',
|
||||
med400: '#9B9B9B',
|
||||
med500: '#868686',
|
||||
med600: '#727272',
|
||||
dark700: '#5E5E5E',
|
||||
dark800: '#4B4B4B',
|
||||
dark900: '#262626'
|
||||
},
|
||||
planner: {
|
||||
light50: '#A5FFFF',
|
||||
light100: '#83EDFA',
|
||||
light200: '#60D7E5',
|
||||
light300: '#38C1D0',
|
||||
med400: '#00ACBB',
|
||||
med500: '#0096A6',
|
||||
med600: '#008190',
|
||||
dark700: '#006C7B',
|
||||
dark800: '#005865',
|
||||
dark900: '#004450'
|
||||
},
|
||||
memory: {
|
||||
light50: '#FFCFAD',
|
||||
light100: '#FFB491',
|
||||
light200: '#FF9976',
|
||||
light300: '#FF7F5E',
|
||||
med400: '#FF6747',
|
||||
med500: '#EE4F33',
|
||||
med600: '#D43721',
|
||||
dark700: '#B82010',
|
||||
dark800: '#9B0502',
|
||||
dark900: '#7D0000'
|
||||
}
|
||||
}
|
|
@ -1,2 +1,56 @@
|
|||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import WorldMap from '$lib/components/WorldMap.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import BottomBar from '$lib/components/BottomBar.svelte';
|
||||
import NewTripPopup from '$lib/components/NewTripPopup.svelte';
|
||||
import Nav from '$lib/components/Nav.svelte';
|
||||
|
||||
let showNewTripPopup = false;
|
||||
|
||||
const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY;
|
||||
|
||||
function handleNewTrip() {
|
||||
showNewTripPopup = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Nav activeTab="Home" />
|
||||
|
||||
<div class="map-container">
|
||||
<WorldMap />
|
||||
</div>
|
||||
|
||||
<div class="floating-button">
|
||||
<Button text="+ Plan a new trip" type="single" onClick={handleNewTrip} />
|
||||
</div>
|
||||
|
||||
<BottomBar onClick={() => goto('/trips')} />
|
||||
|
||||
<NewTripPopup bind:showPopup={showNewTripPopup} fromPage="home" />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--gray-50);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
366
src/routes/itinerary/+page.svelte
Normal file
366
src/routes/itinerary/+page.svelte
Normal file
|
@ -0,0 +1,366 @@
|
|||
<script lang="ts">
|
||||
import '../../app.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { Loader } from '@googlemaps/js-api-loader';
|
||||
import { browser } from '$app/environment';
|
||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||
import BottomBar from '$lib/components/BottomBar.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ItineraryDate from '$lib/components/ItineraryDate.svelte';
|
||||
import AddPlaces from '$lib/components/AddPlaces.svelte';
|
||||
import PlaceCard from '$lib/components/PlaceCard.svelte';
|
||||
|
||||
// Placeholder data obtained from the popup
|
||||
let destination = "Taiwan";
|
||||
let desc = `Click to view all past trips to ${destination}`;
|
||||
let startDate = "27/04/2025";
|
||||
let endDate = "30/04/2025";
|
||||
let places: string[] = [];
|
||||
const place_placeholder = { name: 'Somewhere'}
|
||||
const places_placeholder = Array(3).fill(place_placeholder);
|
||||
|
||||
const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY;
|
||||
let mapContainer: HTMLDivElement;
|
||||
|
||||
onMount(async () => {
|
||||
if (!browser) return;
|
||||
|
||||
if (!GOOGLE_PLACES_API_KEY) {
|
||||
console.error('Google Places API key is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = new Loader({
|
||||
apiKey: GOOGLE_PLACES_API_KEY,
|
||||
version: "weekly",
|
||||
libraries: ["places"],
|
||||
language: 'en'
|
||||
});
|
||||
|
||||
try {
|
||||
await loader.importLibrary("maps");
|
||||
|
||||
const map = new google.maps.Map(mapContainer, {
|
||||
center: { lat: 23.5, lng: 121 }, // Taiwan's coordinates
|
||||
zoom: 8,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading Google Maps:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Array of dates between startDate to endDate
|
||||
// TODO: implement generateTripDates(startDate, endDate)
|
||||
let tripDates = ["27/04/2025", "28/04/2025", "29/04/2025", "30/04/2025"];
|
||||
let expandedSections = {
|
||||
explore: true,
|
||||
places_to_visit: true,
|
||||
itinerary: true
|
||||
};
|
||||
let expandedDates: Record<string, boolean> = {};
|
||||
tripDates.forEach(date => expandedDates[date] = false);
|
||||
|
||||
let recommendedPlaces = [
|
||||
{ name: "Place name" },
|
||||
{ name: "Place name" },
|
||||
{ name: "Place name" }
|
||||
];
|
||||
|
||||
let placesToVisit = [
|
||||
{ name: "Place name"},
|
||||
{ name: "Place name"},
|
||||
{ name: "Place name"}
|
||||
];
|
||||
|
||||
function handleDeletePlace(index: number) {
|
||||
placesToVisit = placesToVisit.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function toggleSection(section: keyof typeof expandedSections) {
|
||||
expandedSections[section] = !expandedSections[section];
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
// Get the 'from' parameter from the URL
|
||||
const fromPage = page.url.searchParams.get('from');
|
||||
console.log(`fromPage = ${fromPage}`);
|
||||
|
||||
if (fromPage === 'trips') {
|
||||
goto('/trips');
|
||||
} else {
|
||||
goto('/');
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlaceSelected(place: google.maps.places.PlaceResult) {
|
||||
const newPlace = {
|
||||
name: place.name || 'Unknown Place',
|
||||
desc: place.formatted_address || '',
|
||||
img: place.photos ? place.photos[0].getUrl() : 'placeholder.jpeg'
|
||||
};
|
||||
|
||||
placesToVisit = [...placesToVisit, newPlace];
|
||||
}
|
||||
|
||||
function handlePastTrip() {
|
||||
console.log(`see past trips to ${destination}`)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
console.log('cancel update');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
console.log('save update');
|
||||
}
|
||||
|
||||
function showPastTrips() {
|
||||
// TODO: Implement past trips view
|
||||
console.log('Show past trips');
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="plan-section">
|
||||
<header>
|
||||
<div class="back-btn-wrapper">
|
||||
<button class="back-btn" onclick={handleBack} aria-label="Back">
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="trip-info">
|
||||
<h1>Trip to {destination}</h1>
|
||||
<p class="date">{startDate} - {endDate}</p>
|
||||
</div>
|
||||
|
||||
<div class="tripmates">
|
||||
<ProfilePicture zIndex=1/>
|
||||
<ProfilePicture marginLeft=-15px zIndex=0/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<section class="places-section">
|
||||
<button class="section-header" onclick={() => toggleSection('places_to_visit')}>
|
||||
<div class="section-text">
|
||||
<i class="fa-solid fa-chevron-right arrow-icon" class:rotated={expandedSections.places_to_visit}></i>
|
||||
<h2>Places to Visit</h2>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- TODO: implement the content part -->
|
||||
{#if expandedSections.places_to_visit}
|
||||
<div
|
||||
class="section-content places"
|
||||
transition:slide={{ duration: 400, easing: quintOut }}
|
||||
>
|
||||
<div class="added-places">
|
||||
{#each placesToVisit as place, i}
|
||||
<PlaceCard
|
||||
variant="simple"
|
||||
place={place}
|
||||
onDelete={() => handleDeletePlace(i)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<AddPlaces
|
||||
onPlaceSelected={handlePlaceSelected}
|
||||
countryRestriction="tw"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="itinerary-section">
|
||||
<button class="section-header" onclick={() => toggleSection('itinerary')}>
|
||||
<div class="section-text">
|
||||
<i class="fa-solid fa-chevron-right arrow-icon" class:rotated={expandedSections.itinerary}></i>
|
||||
<h2>Itinerary</h2>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if expandedSections.itinerary}
|
||||
<div
|
||||
class="section-content"
|
||||
transition:slide={{ duration: 400, easing: quintOut }}
|
||||
>
|
||||
{#each tripDates as date}
|
||||
<ItineraryDate
|
||||
{date}
|
||||
isExpanded={expandedDates[date]}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="button-group">
|
||||
<Button text="Cancel" type="gray" onClick={handleCancel}/>
|
||||
<!-- later edit this so button turns blue only when there is changes to the plan -->
|
||||
<Button text="Save" type="blue" onClick={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-section">
|
||||
<div class="map-container" bind:this={mapContainer}></div>
|
||||
<BottomBar title="Past Trips" desc={desc} onClick={handlePastTrip} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 50% 50%;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.plan-section {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0rem 0rem 0rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #84D7EB;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 2rem 1.5rem 1rem;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.back-btn-wrapper {
|
||||
align-self: flex-start;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
color: var(--gray-400);
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: var(--gray-100);
|
||||
}
|
||||
|
||||
.trip-info {
|
||||
flex: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.trip-info h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--gray-400);
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tripmates {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem 1.5rem 0 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
box-sizing: border-box;
|
||||
padding: 0.75rem 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
transition: transform 0.3s ease;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.section-text h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding-left: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-content.places{
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--white);
|
||||
padding: 1.5rem 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
60
src/routes/memories/+page.svelte
Normal file
60
src/routes/memories/+page.svelte
Normal file
|
@ -0,0 +1,60 @@
|
|||
<script>
|
||||
import '/src/app.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import WorldMap from '$lib/components/WorldMap.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import NewMemoryPopup from '$lib/components/NewMemoryPopup.svelte';
|
||||
import Nav from '$lib/components/Nav.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let showNewMemoryPopup = false;
|
||||
|
||||
const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY;
|
||||
|
||||
onMount(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
return () => document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
function handleNewMemory() {
|
||||
showNewMemoryPopup = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Nav activeTab="Memory" darkMode={true} />
|
||||
|
||||
<div class="map-container">
|
||||
<WorldMap />
|
||||
</div>
|
||||
|
||||
<div class="floating-button">
|
||||
<Button text="+ Add a new memory" type="memory" onClick={handleNewMemory} />
|
||||
</div>
|
||||
|
||||
<NewMemoryPopup bind:showPopup={showNewMemoryPopup} />
|
||||
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--gray-50);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
207
src/routes/trips/+page.svelte
Normal file
207
src/routes/trips/+page.svelte
Normal file
|
@ -0,0 +1,207 @@
|
|||
<script lang="ts">
|
||||
import '../../app.css';
|
||||
import TripCard from '$lib/components/TripCard.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import NewTripPopup from '$lib/components/NewTripPopup.svelte';
|
||||
import Nav from '$lib/components/Nav.svelte';
|
||||
|
||||
interface Trip {
|
||||
destination: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
let activeTab = "Ongoing Trips";
|
||||
let showNewTripPopup = false;
|
||||
let contentContainer: HTMLElement;
|
||||
|
||||
// Sample data, replace with actual data later
|
||||
const sample_trip = {
|
||||
destination: "Taiwan",
|
||||
startDate: "04.27.2025",
|
||||
endDate: "04.30.2025",
|
||||
imageUrl: ""
|
||||
}
|
||||
let ongoingTrips = Array(3).fill(sample_trip);
|
||||
|
||||
// let pastTrips: Trip[] = [];
|
||||
let pastTrips = Array(14).fill(sample_trip);
|
||||
|
||||
function handleNewTrip() {
|
||||
showNewTripPopup = true;
|
||||
}
|
||||
|
||||
function handleTabChange(tab: string) {
|
||||
activeTab = tab;
|
||||
contentContainer.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Nav activeTab="Planner" />
|
||||
|
||||
<div class="content" bind:this={contentContainer}>
|
||||
<div class="header">
|
||||
<h1>Your Trips</h1>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class:active={activeTab === "Ongoing Trips"}
|
||||
onclick={() => handleTabChange("Ongoing Trips")}>
|
||||
Ongoing Trips
|
||||
</button>
|
||||
<button
|
||||
class:active={activeTab === "Past Trips"}
|
||||
onclick={() => handleTabChange("Past Trips")}>
|
||||
Past Trips
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="trips-container">
|
||||
{#if activeTab === "Ongoing Trips"}
|
||||
{#if ongoingTrips.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>There is no ongoing trip</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="trips-grid">
|
||||
{#each ongoingTrips as trip}
|
||||
<TripCard {...trip} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if pastTrips.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>There is no past trip</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="trips-grid">
|
||||
{#each pastTrips as trip}
|
||||
<TripCard {...trip} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="floating-button">
|
||||
<Button text="+ Plan a new trip" type="single" onClick={handleNewTrip} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewTripPopup bind:showPopup={showNewTripPopup} fromPage="trips" />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100vh;
|
||||
background-color: var(--white);
|
||||
font-family: 'Inter', sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 0 1rem 2rem 1rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-top: 2rem;
|
||||
padding-left: 1rem;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 1.5rem;
|
||||
padding-left: 1rem;
|
||||
background-color: var(--white);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.75rem 0;
|
||||
color: var(--gray-400);
|
||||
transition: color 0.3s ease;
|
||||
position: relative;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: var(--planner-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs button.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: var(--planner-600);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.trips-container {
|
||||
flex: 1;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trips-grid {
|
||||
display: grid;
|
||||
padding-top: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
margin-top: -10rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--gray-400);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
BIN
static/placeholder.jpeg
Normal file
BIN
static/placeholder.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.9 KiB |
BIN
static/profile-pic.png
Normal file
BIN
static/profile-pic.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
BIN
static/user.png
Normal file
BIN
static/user.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
|
@ -2,5 +2,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
plugins: [sveltekit()],
|
||||
ssr: {
|
||||
noExternal: ['@googlemaps/js-api-loader']
|
||||
}
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue
Block a user