Compare commits

..

27 Commits

Author SHA1 Message Date
6325ec86fe problems with binding memory with trip 2025-06-07 14:17:04 +09:00
b73519c5db NewMemoryPopup error control 2025-06-07 12:08:15 +09:00
adeliptr
c7c4153220 show marker in WorldMap 2025-06-07 00:54:12 +09:00
adeliptr
422c39e3e1 Turn into Itinerary working 2025-06-06 20:25:46 +09:00
adeliptr
1f384c909a Recommend Places by LLM 2025-06-06 19:43:31 +09:00
13f93623a5 test 2025-06-06 19:26:52 +09:00
8ef8712a76 link mwmory part to firebase 2025-06-06 19:19:28 +09:00
bd6b405478 NewMemoryPopup with problems 2025-06-06 18:58:10 +09:00
adeliptr
00754bd636 connect trips and itinerary to database 2025-06-06 15:52:53 +09:00
7b0740fe0a viewimage page test 2025-06-06 15:40:18 +09:00
c21a5a1ab8 viewmemory page 2025-06-06 15:39:39 +09:00
82d7418567 add google api and auto complete in custom location part 2025-06-06 04:12:07 +09:00
adeliptr
9def1973a7 connect to database 2025-06-06 00:25:21 +09:00
adeliptr
143ac1d2ea add image to PlaceCard 2025-06-05 14:03:42 +09:00
3caa21522e add dropdown + change pages 2025-06-04 05:13:57 +09:00
adeliptr
8d494cbe23 fix NewTripPopup 2025-06-02 21:27:59 +09:00
adeliptr
9ccf0a741a AddPlaces with autocomplete 2025-06-01 23:59:54 +09:00
db6c421b47 memory css finish 2025-06-01 18:49:23 +09:00
5f75a328b0 WorldMap Darkmode 2025-06-01 18:48:21 +09:00
e4f4f9d23b NewMemoryPopup finish 2025-06-01 14:56:11 +09:00
562236acb3 memory page header, floating button 2025-06-01 01:22:55 +09:00
adeliptr
e25f5f1897 added map @ itinerary page 2025-05-31 23:39:27 +09:00
adeliptr
5b0a51a6f4 added trips page 2025-05-31 14:53:07 +09:00
adeliptr
86c84b42e9 itinerary page: itinerary section 2025-05-30 23:49:58 +09:00
adeliptr
21ff12f6b8 add world map @ home page 2025-05-29 14:58:46 +09:00
adeliptr
595e984b12 add Colors constants 2025-05-29 14:15:08 +09:00
adeliptr
01f4f81651 basic home page 2025-05-28 22:48:47 +09:00
36 changed files with 7098 additions and 22 deletions

View File

@ -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 First, you need to clone this repository by clicking the triple dots button → `Open with VS Code` or using the command below:
```
If you're seeing this, you've probably already done this step. Congrats! git clone http://git.prototyping.id/20210782/travel-app.git
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
``` ```
Then, you need to download the project dependencies by typing this command:
```
npm install
```
## Developing ## 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 ```bash
npm run dev npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
``` ```
## Building ## Building

2258
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,9 +15,22 @@
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.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": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^6.2.6" "vite": "^6.2.6"
},
"dependencies": {
"@googlemaps/js-api-loader": "^1.16.8",
"d3": "^7.9.0",
"firebase": "^11.9.0",
"openai": "^5.1.1",
"topojson-client": "^3.1.0",
"topojson-server": "^3.0.1"
} }
} }

40
src/app.css Normal file
View 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;
}

View File

@ -2,8 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

20
src/firebase.js Normal file
View File

@ -0,0 +1,20 @@
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { ref, child, get, set, getDatabase, onValue, push } from 'firebase/database';
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
export const db = getDatabase(app);

View File

@ -0,0 +1,217 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Loader } from '@googlemaps/js-api-loader';
// Extend the PlaceResult type to include our custom photoUrl
interface ExtendedPlaceResult extends google.maps.places.PlaceResult {
photoUrl?: string;
}
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 = crypto.randomUUID(); // Generate unique ID for each instance
let inputContainer: HTMLDivElement;
let inputWrapper: HTMLDivElement;
let showAddButton = false;
let lastSelectedPlaceName = '';
let selectedPlace: ExtendedPlaceResult | 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 = `places-input-${id}`;
input.className = 'places-input';
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', 'geometry']);
autocomplete.addListener('place_changed', () => {
const place = autocomplete.getPlace() as ExtendedPlaceResult;
if (place && place.name) {
// If the place has photos, get the URL for the first photo
if (place.photos && place.photos.length > 0) {
try {
const photoOptions = {
maxWidth: 400,
maxHeight: 300
};
place.photoUrl = place.photos[0].getUrl(photoOptions);
} catch (error) {
console.error('Error getting photo URL:', error);
place.photoUrl = '/placeholder.jpeg';
}
}
else {
place.photoUrl = '/placeholder.jpeg';
}
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(.places-input) {
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(.places-input:hover) {
background-color: var(--gray-100);
border-color: var(--gray-100);
}
:global(.places-input: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);
}
:global(.pac-container:after) {
display: none;
}
</style>

View 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>

View File

@ -0,0 +1,100 @@
<script>
import '../../app.css';
export let text = 'Button';
export let type = 'single';
export let onClick = () => {};
export let disabled = false;
</script>
<button class="{type}" on:click={onClick} {disabled}>
{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;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
}
button:disabled:hover {
opacity: 0.5;
transform: none;
}
.single {
background-color: var(--planner-300);
color: var(--white);
font-weight: 600;
}
.single:hover {
opacity: 0.75;
transform: scale(1.02);
}
.full-gray {
width: 100%;
background-color: var(--gray-50);
color: var(--gray-400);
}
.full-gray:hover {
background-color: var(--gray-100);
color: var(--gray-600);
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>

View File

@ -0,0 +1,91 @@
<script lang="ts">
export let showPopup = false;
export let destination = '';
export let onConfirm: () => void;
export let onCancel: () => void;
function handlePopupClick(event: MouseEvent) {
event.stopPropagation();
}
</script>
{#if showPopup}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="overlay" role="button" tabindex="0">
<div class="popup" role="button" tabindex="0" onclick={handlePopupClick}>
<h2>Delete Trip</h2>
<p>Are you sure you want to delete Trip to {destination}?</p>
<div class="button-group">
<button class="cancel-btn" onclick={onCancel}>Cancel</button>
<button class="delete-btn" onclick={onConfirm}>Yes, delete</button>
</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: 90%;
max-width: 400px;
box-shadow: 0 0px 20px rgba(0, 0, 0, 0.1);
}
.popup h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--memory-600);
text-align: center;
margin-bottom: 1.5rem;
}
.popup p {
margin: 1rem 0 2rem 0;
color: var(--gray-600);
text-align: center;
}
.button-group {
display: flex;
gap: 1rem;
justify-content: center;
}
button {
padding: 0.75rem 1.5rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
background: var(--gray-100);
border: none;
color: var(--gray-600);
width: 50%;
}
.cancel-btn:hover {
opacity: 0.75;
}
.delete-btn:hover {
background: var(--memory-400);
color: white;
}
</style>

View File

@ -0,0 +1,126 @@
<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 countryCode = 'tw'; // Default to Taiwan if not provided
interface Place {
name: string;
desc?: string;
image?: string;
time?: string;
geometry?: {
lat: number;
lng: number;
};
}
export let places: Place[] = [];
export let onPlacesUpdate: (places: Place[]) => void;
function toggleDate() {
isExpanded = !isExpanded;
}
function handlePlaceSelected(place: google.maps.places.PlaceResult) {
const newPlace = {
name: place.name || 'Unknown Place',
desc: place.formatted_address || '',
image: (place as any).photoUrl || '/placeholder.jpeg',
time: 'Add Time',
geometry: place.geometry?.location ? {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng()
} : undefined
};
const updatedPlaces = [...places, newPlace];
places = updatedPlaces;
onPlacesUpdate(updatedPlaces);
}
function handleDeletePlace(index: number) {
const updatedPlaces = places.filter((_, i) => i !== index);
places = updatedPlaces;
onPlacesUpdate(updatedPlaces);
}
</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={countryCode}
/>
</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>

View File

@ -0,0 +1,58 @@
<script>
import { fade } from 'svelte/transition';
export let show = false;
export let message = '';
</script>
{#if show}
<div class="overlay" transition:fade={{ duration: 200 }}>
<div class="content" transition:fade={{ duration: 150, delay: 50 }}>
<div class="spinner"></div>
<p>{message}</p>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
p {
color: white;
font-size: 1.2rem;
margin: 0;
font-family: 'Inter', sans-serif;
font-weight: 500;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--white);
border-top: 3px solid transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,70 @@
<script>
export let destination = '';
export let startDate = '';
export let endDate = '';
export let image = '';
</script>
<div class="memory-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-800)"></i>
</div>
{/if}
</div>
<div class="info">
<h3>{destination}</h3>
<p class="date">{startDate} - {endDate}</p>
</div>
</div>
<style>
.memory-card {
background: var(--black);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
font-family: 'Inter', sans-serif;
color: var(--white);
}
.memory-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(255, 255, 255, 0.1);
}
.image {
height: 160px;
background-size: cover;
background-position: center;
background-color: var(--gray-900);
}
.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>

View File

@ -0,0 +1,223 @@
<script>
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
/**
*
* @param e {any}
*/
const handleClickOutside = (e) => {
if (!e.target.closest('.profile')) {
showDropdown = false;
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
let title = "Travel App";
export let activeTab = "Planner";
export let darkMode = false;
let showDropdown = false;
/**
*
* @param tab {string}
*/
function handleNavigation(tab) {
activeTab = tab;
if (tab === 'Planner') {
goto('/');
} else if (tab === 'Memory') {
goto('/memories');
} else if (tab === 'MyTrip') {
goto('/trips');
} else if (tab === 'MyMemory') {
goto('/mymemory');
} 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 === "Planner"}
onclick={() => handleNavigation("Planner")}>
Planner
</button>
<button
class:active={activeTab === "Memory"}
onclick={() => handleNavigation("Memory")}>
Memory
</button>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="profile"
onmouseenter={() => showDropdown = true}
onmouseleave={() => showDropdown = false}>
<button class="profile-btn" aria-label="Open profile">
<i class="fa-regular fa-user fa-xl"></i>
</button>
{#if showDropdown}
<div class="dropdown">
<button onclick={() => handleNavigation("MyTrip")}>My Trips</button>
<button onclick={() => handleNavigation("MyMemory")}>My Memories</button>
<button>Log out</button>
</div>
{/if}
</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;
}
.dropdown {
position: absolute;
top: 3.5rem;
right: 2rem;
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: 0.5rem;
padding: 0.5rem 0;
z-index: 999;
display: flex;
flex-direction: column;
min-width: 150px;
}
.dropdown button {
background: none;
border: none;
padding: 0.75rem 1rem;
font-size: 0.9rem;
text-align: left;
color: var(--black);
cursor: pointer;
transition: background 0.2s ease;
}
.dropdown button:hover {
background-color: var(--gray-100);
}
nav.dark-mode .dropdown {
background: var(--gray-900);
border: 1px solid var(--gray-700);
}
nav.dark-mode .dropdown button {
color: var(--white);
}
nav.dark-mode .dropdown button:hover {
background-color: var(--gray-800);
}
nav.dark-mode {
background-color: var(--black);
border-bottom: 1px solid var(--gray-200);
}
nav.dark-mode .logo {
font-size: 1.5rem;
font-weight: bold;
color: var(--white);
}
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>

View File

@ -0,0 +1,485 @@
<script lang="ts">
import '../../app.css';
import Button from '$lib/components/Button.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { Loader } from '@googlemaps/js-api-loader';
import { ref, push, onValue } from 'firebase/database';
import { db } from '../../firebase';
export let showPopup = false;
export let onAddMemory = () => {};
export let onCancel = () => {};
export let tid: string;
let startDate = '';
let endDate = '';
let isGoogleLoaded = false;
let dragActive = false;
let customLocation = '';
let customLocationInput: HTMLInputElement;
let images: File[] = [];
let showLocationError = false;
let showImageError = false;
let hasAttemptedSubmit = false;
let isFormValid = true;
let selectedTripId = ''; //for dropdown
let selectedLocation = '';
let tripOptions: { value: string; label: string }[] = [];
////////// THIS PART - load destination, startDate, and endDate from previous trip ////////
onMount(() => {
const tripsRef = ref(db, 'trips');
onValue(tripsRef, snapshot => {
const options: { value: string; label: string }[] = [];
snapshot.forEach(child => {
const val = child.val();
const tripId = child.key;
const { name } = val.destination;
const start = new Date(val.startDate);
const end = new Date(val.endDate);
const format = (d: Date) => `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
options.push({
value: tripId,
label: `${name} (${format(start)} - ${format(end)})`
});
});
tripOptions = options;
});
});
$: if (hasAttemptedSubmit) {
isFormValid = (
(selectedLocation !== '' && (!isCustomLocation() || customLocation.trim() !== '')) &&
images.length > 0
);
}
$: if (selectedTripId && selectedTripId !== 'custom') {
const trip = tripOptions.find(t => t.value === selectedTripId);
if (trip) {
selectedLocation = trip.label.split(' (')[0]; // label에서 name 추출
const tripRef = ref(db, `trips/${selectedTripId}`);
onValue(tripRef, (snapshot) => {
const val = snapshot.val();
startDate = val.startDate;
endDate = val.endDate;
});
}
}
function isCustomLocation() {
return selectedTripId === 'custom';
}
const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY;
onMount(async () => {
if (!GOOGLE_PLACES_API_KEY) return;
const loader = new Loader({
apiKey: GOOGLE_PLACES_API_KEY,
version: 'weekly',
libraries: ['places'],
language: 'en'
});
try {
await loader.importLibrary('places');
isGoogleLoaded = true;
} catch (error) {
console.error('Error loading Places Autocomplete:', error);
}
});
$: if (isGoogleLoaded && isCustomLocation() && customLocationInput) {
const autocompleteCustom = new google.maps.places.Autocomplete(customLocationInput, {
types: ['(regions)']
});
autocompleteCustom.setFields(['name']);
autocompleteCustom.addListener('place_changed', () => {
const place = autocompleteCustom.getPlace();
if (place.name) {
customLocation = place.name;
showLocationError = false;
}
});
}
function handleFiles(files: FileList) {
for (const file of files) {
if (file.type.startsWith('image/')) {
images = [...images, file];
}
}
}
function handleCancelClick() {
onCancel();
reset();
}
function handleDrop(event: DragEvent) {
event.preventDefault();
dragActive = false;
handleFiles(event.dataTransfer!.files);
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
dragActive = true;
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
dragActive = false;
}
function handleInputChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.files) {
handleFiles(target.files);
}
}
function removeImage(imageToRemove: File) {
images = images.filter(img => img !== imageToRemove);
}
async function handleAddMemory() {
hasAttemptedSubmit = true;
showLocationError = selectedLocation === '' || (isCustomLocation() && customLocation.trim() === '');
showImageError = images.length === 0;
if (showLocationError || showImageError) return;
const finalLocation = isCustomLocation() ? customLocation : selectedLocation;
const newMemory = {
location: finalLocation,
startDate,
endDate,
images: images.map(file => URL.createObjectURL(file)),
createdAt: new Date().toISOString()
};
////////// THIS PART - add memory node on trips /////////
try {
const memoryRef = ref(db, `trips/${tid}/memories`);
const newMemory = {
location: finalLocation,
startDate,
endDate,
images: images.map(file => URL.createObjectURL(file)),
createdAt: new Date().toISOString()
};
const addedRef = await push(memoryRef, newMemory);
reset();
goto(`/viewimage?id=${addedRef.key}`);
} catch (error) {
console.error('Error saving memory:', error);
}
}
function reset() {
showPopup = false;
selectedLocation = '';
customLocation = '';
images = [];
startDate = '';
endDate = '';
showLocationError = false;
showImageError = false;
}
const locations = ['Paris', 'Tokyo', 'New York'];
</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>
<!-- THIS PART - binding informaions to dropdown options-->
<select bind:value={selectedTripId}>
<option value="" disabled>Select location</option>
{#each tripOptions as opt}
<option value={opt.value}>{opt.label}</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:this={customLocationInput}
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">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>Upload images</label>
<div class="drop-area {dragActive ? 'active' : ''}"
role="button"
tabindex="0"
on:drop={handleDrop}
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
>
<input
type="file"
accept="image/*"
multiple
on:change={handleInputChange}
style="display: none;"
id="fileInput"
/>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="drop-label" role="button" tabindex="0" 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">
<button class="delete-button" on:click={() => removeImage(img)}>×</button>
<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={hasAttemptedSubmit && !isFormValid}/>
</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 {
position: relative;
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);
}
.delete-button {
position: absolute;
top: 4px;
right: 4px;
background: rgba(38, 38, 38, 0.5);
border: none;
color: var(--white);
border-radius: 50%;
width: 18px;
height: 18px;
font-size: 0.9rem;
cursor: pointer;
z-index: 2;
}
.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>

View File

@ -0,0 +1,438 @@
<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 { ref, child, get, set, onValue, push } from 'firebase/database';
import { db } from '../../firebase';
import Button from './Button.svelte';
export let showPopup = false;
let destination = "";
let selectedPlace: any;
let lastSelectedPlaceName = "";
let startDate = "";
let endDate = "";
let tripmates: string[] = [];
let currentEmail = "";
let destinationError = false;
let startDateError = false;
let endDateError = false;
let dateOrderError = false;
let destinationInput: HTMLDivElement;
let autocomplete: google.maps.places.Autocomplete | null = null;
let isGoogleLoaded = false;
const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY;
// Add reactive statements to clear errors when valid input is provided
$: if (destination) destinationError = false;
$: if (startDate) startDateError = false;
$: if (endDate) endDateError = false;
// Clear date order error when either date changes
$: if (startDate && endDate) {
const startDateTime = new Date(startDate).getTime();
const endDateTime = new Date(endDate).getTime();
if (endDateTime >= startDateTime) {
dateOrderError = false;
startDateError = false;
endDateError = false;
}
}
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");
isGoogleLoaded = true;
} catch (error) {
console.error('Error loading Places Autocomplete:', error);
}
});
async function initializeAutocomplete() {
if (!isGoogleLoaded || !destinationInput) return;
const input = document.createElement('input');
input.type = 'text';
input.id = 'destination-input';
input.placeholder = 'Where do you want to go?';
destinationInput.appendChild(input);
autocomplete = new google.maps.places.Autocomplete(input, {
types: ['(regions)']
});
autocomplete.setFields(['name', 'formatted_address', 'photos', 'place_id', 'geometry']);
autocomplete.addListener('place_changed', () => {
if (!autocomplete) return;
const place = autocomplete.getPlace();
if (place.name) {
destination = place.name;
selectedPlace = place;
lastSelectedPlaceName = input.value.trim();
destinationError = false;
}
});
let placeChangeTimeout;
input.addEventListener('blur', () => {
// Use a small delay to allow place_changed event to fire first
placeChangeTimeout = setTimeout(() => {
const trimmed = input.value.trim();
if (trimmed && trimmed !== lastSelectedPlaceName) {
destinationError = true;
}
else if (!trimmed) {
// Don't show error for empty field
destinationError = false;
destination = "";
}
}, 400);
});
}
function isValidEmail(email: string) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function handleEmailInput(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
const email = currentEmail.trim();
if (email && isValidEmail(email) && !tripmates.includes(email)) {
tripmates = [...tripmates, email];
currentEmail = "";
}
}
}
function removeEmail(emailToRemove: string) {
tripmates = tripmates.filter(email => email !== emailToRemove);
}
function handleCancel() {
showPopup = false;
destination = "";
startDate = "";
endDate = "";
tripmates = [];
currentEmail = "";
destinationError = false;
startDateError = false;
endDateError = false;
if (autocomplete) {
google.maps.event.clearInstanceListeners(autocomplete);
autocomplete = null;
}
}
async function handleStart() {
destinationError = !destination;
startDateError = !startDate;
endDateError = !endDate;
// Check if both dates are filled before comparing them
if (startDate && endDate) {
const startDateTime = new Date(startDate).getTime();
const endDateTime = new Date(endDate).getTime();
if (endDateTime < startDateTime) {
startDateError = true;
endDateError = true;
dateOrderError = true;
return;
}
dateOrderError = false;
}
if (destinationError || startDateError || endDateError) {
return;
}
else {
const tid = crypto.randomUUID();
// Extract required place details
const placeDetails = {
name: selectedPlace.name,
formatted_address: selectedPlace.formatted_address,
photo: selectedPlace.photos?.[0]?.getUrl(),
location: {
lat: selectedPlace.geometry.location.lat(),
lng: selectedPlace.geometry.location.lng()
}
};
const tripData = {
tid,
destination: placeDetails,
startDate,
endDate,
tripmates,
created_at: new Date().toISOString()
};
try {
// Create a new reference for this specific trip using its ID
const tripRef = ref(db, `trips/${tid}`);
await set(tripRef, tripData);
console.log(`Trip saved to db with ID: ${tid}`);
goto(`/itinerary/${tid}`);
handleCancel();
} catch (error) {
console.error('Error saving trip:', error);
}
}
}
$: if (showPopup && isGoogleLoaded && destinationInput) {
initializeAutocomplete();
}
</script>
{#if showPopup}
<div class="overlay">
<div class="popup">
<h1>Start a New Plan</h1>
<div class="input-form">
<label for="destination">Destination</label>
<div bind:this={destinationInput} class="destination-wrapper" id="destination"></div>
{#if destinationError}
<p class="error-message">Please enter your destination</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}
required
/>
{#if startDateError}
<p class="error-message">
{dateOrderError ? 'Start date must be before end date' : 'Please enter the starting date'}
</p>
{/if}
</div>
<div class="input-form">
<label for="end-date">End Date</label>
<input
type="date"
id="end-date"
bind:value={endDate}
required
/>
{#if endDateError}
<p class="error-message">
{dateOrderError ? 'End date must be after start date' : 'Please enter the ending date'}
</p>
{/if}
</div>
</div>
<div class="input-form">
<label for="trip-friends">
<span class="invite-label">
+ Invite Tripmates
<i class="fa-solid fa-user-group" style="color: {Colors.gray.dark800}"></i>
</span>
</label>
<div class="email-input-container">
{#each tripmates as email}
<div class="email-tag">
<span>{email}</span>
<button class="remove-email" onclick={() => removeEmail(email)}>×</button>
</div>
{/each}
<input
type="email"
id="trip-friends"
bind:value={currentEmail}
onkeydown={handleEmailInput}
placeholder={tripmates.length ? "" : "Enter email addresses"}
/>
</div>
</div>
<div class="button-group">
<Button text="Cancel" type="gray" onClick={handleCancel} />
<Button text="Start" type="blue" onClick={handleStart} disabled={destinationError || startDateError || endDateError}/>
</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);
}
.error-message {
color: var(--memory-500);
margin: 0.5rem 0 0 0;
font-size: 0.8rem;
}
.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;
}
.email-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--gray-200);
border-radius: 8px;
min-height: 2.5rem;
align-items: center;
}
.email-input-container input {
border: none;
outline: none;
flex: 1;
min-width: 50px;
padding: 0;
padding-left: 0.25rem;
}
.email-input-container input:focus {
outline: none;
}
.email-tag {
display: flex;
align-items: center;
background-color: var(--gray-100);
padding: 0.25rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
gap: 0.5rem;
}
.email-tag:hover {
background-color: var(--gray-200);
opacity: 0.8;
}
.remove-email {
background: none;
border: none;
color: var(--gray-600);
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
display: flex;
align-items: center;
}
.remove-email:hover {
color: var(--memory-600);
}
</style>

View File

@ -0,0 +1,224 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import TripCard from './TripCard.svelte';
export let showPanel = false;
export let destination = '';
export let pastTrips: any[] = [];
export let onClose = () => {};
let tripsContainer: HTMLElement;
let showLeftButton = false;
let showRightButton = true;
function handleScroll() {
if (!tripsContainer) return;
// Show/hide left button based on scroll position
showLeftButton = tripsContainer.scrollLeft > 0;
// Show/hide right button based on whether we can scroll more
const maxScroll = tripsContainer.scrollWidth - tripsContainer.clientWidth;
showRightButton = Math.ceil(tripsContainer.scrollLeft) < maxScroll;
}
function scrollLeft() {
if (!tripsContainer) return;
const scrollAmount = tripsContainer.clientWidth * 0.8; // Scroll 80% of container width
tripsContainer.scrollBy({
left: -scrollAmount,
behavior: 'smooth'
});
}
function scrollRight() {
if (!tripsContainer) return;
const scrollAmount = tripsContainer.clientWidth * 0.8; // Scroll 80% of container width
tripsContainer.scrollBy({
left: scrollAmount,
behavior: 'smooth'
});
}
</script>
{#if showPanel}
<div
class="panel"
transition:slide={{ duration: 400, easing: quintOut }}
>
<div class="header">
<h2>Past Trips to {destination}</h2>
<button class="close-btn" onclick={onClose} aria-label="Close panel">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="content">
{#if pastTrips.length === 0}
<div class="message">This is your first trip to {destination}!</div>
{:else}
<div class="trips-scroll-container">
{#if showLeftButton}
<button
class="scroll-btn left"
onclick={scrollLeft}
aria-label="Scroll left"
transition:slide={{ duration: 200, easing: quintOut }}
>
<i class="fa-solid fa-chevron-left"></i>
</button>
{/if}
<div
class="trips-container"
bind:this={tripsContainer}
onscroll={handleScroll}
>
{#each pastTrips as trip}
<TripCard
destination={trip.destination.name}
startDate={new Date(trip.startDate).toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}
endDate={new Date(trip.endDate).toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}
image={trip.destination.photo}
tid={trip.tid}
/>
{/each}
</div>
{#if showRightButton}
<button
class="scroll-btn right"
onclick={scrollRight}
aria-label="Scroll right"
transition:slide={{ duration: 200, easing: quintOut }}
>
<i class="fa-solid fa-chevron-right"></i>
</button>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<style>
.panel {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 50vh;
background: var(--white);
border-radius: 20px 20px 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
z-index: 10;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--gray-100);
}
.header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 0.5rem;
color: var(--gray-400);
border-radius: 50%;
transition: all 0.2s ease;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background-color: var(--gray-100);
}
.content {
flex: 1;
padding: 1.5rem 2rem;
overflow: hidden;
}
.message {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-400);
font-size: 1.1rem;
}
.trips-scroll-container {
position: relative;
height: 100%;
}
.trips-container {
display: flex;
gap: 1.5rem;
overflow-x: auto;
padding-bottom: 1rem;
scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
}
.trips-container::-webkit-scrollbar {
display: none;
}
.trips-container :global(.trip-card) {
min-width: 280px;
width: 280px;
}
.scroll-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: var(--white);
border: none;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
color: var(--gray-600);
transition: all 0.2s ease;
z-index: 1;
}
.scroll-btn:hover {
background-color: var(--gray-50);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.scroll-btn.left {
left: -1.25rem;
}
.scroll-btn.right {
right: -1.25rem;
}
</style>

View File

@ -0,0 +1,253 @@
<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%;
aspect-ratio: 16 / 9;
height: auto;
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>

View File

@ -0,0 +1,55 @@
<script lang="ts">
export let friends = 1;
export let images: string[] = [];
</script>
<div class="profile-pictures">
{#each Array(friends) as _, i}
<div
class="profile-picture"
style="z-index: {friends - i}; margin-left: {i === 0 ? '0' : '-20px'}"
>
{#if images[i]}
<img class="profile-img" src={images[i]} alt="Profile" />
{:else}
<div class="default-avatar">
<i class="fa-solid fa-user"></i>
</div>
{/if}
</div>
{/each}
</div>
<style>
.profile-pictures {
display: flex;
align-items: center;
}
.profile-picture {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
border: 2px solid white;
background-color: var(--gray-100);
overflow: hidden;
flex-shrink: 0;
}
.profile-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.default-avatar {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-400);
font-size: 1.2rem;
}
</style>

View File

@ -0,0 +1,67 @@
<script lang="ts">
import Button from './Button.svelte';
export let showPopup = false;
export let destination = '';
export let onSelect: (type: 'new' | 'old' | 'mix') => void;
export let onCancel: () => void;
</script>
{#if showPopup}
<div class="popup-overlay">
<div class="popup">
<h2>Get Recommendations</h2>
<p>You've been to {destination} before. What type of recommendations would you like?</p>
<div class="buttons">
<Button text="New Places" type="single" onClick={() => onSelect('new')} />
<Button text="Places I've Been to" type="single" onClick={() => onSelect('old')} />
<Button text="Mix of Both" type="single" onClick={() => onSelect('mix')} />
<Button text="Cancel" type="full-gray" onClick={onCancel} />
</div>
</div>
</div>
{/if}
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.popup {
background: white;
padding: 2rem;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--gray-900);
}
p {
margin: 1rem 0 1.5rem;
color: var(--gray-600);
line-height: 1.5;
}
.buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@ -0,0 +1,159 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import DeleteConfirmationPopup from './DeleteConfirmationPopup.svelte';
import { ref, remove } from 'firebase/database';
import { db } from '../../firebase';
export let destination = '';
export let startDate = '';
export let endDate = '';
export let image = '';
export let tid = '';
let showDelete = false;
let showDeleteConfirmation = false;
function handleClick() {
goto(`/itinerary/${tid}`);
}
function handleMouseEnter() {
showDelete = true;
}
function handleMouseLeave() {
showDelete = false;
}
function handleDeleteClick(event: MouseEvent) {
event.stopPropagation();
showDeleteConfirmation = true;
}
async function handleConfirmDelete() {
try {
const tripRef = ref(db, `trips/${tid}`);
await remove(tripRef);
showDeleteConfirmation = false;
} catch (error) {
console.error('Error deleting trip:', error);
}
}
function handleCancelDelete() {
showDeleteConfirmation = false;
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="trip-card"
role="button"
tabindex="0"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
onclick={handleClick}
>
<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}
{#if showDelete}
<button
class="delete-btn"
onclick={handleDeleteClick}
transition:fade={{ duration: 100 }}
aria-label="Delete trip"
>
<i class="fa-solid fa-xmark"></i>
</button>
{/if}
</div>
<div class="info">
<h3>{destination}</h3>
<p class="date">{startDate} - {endDate}</p>
</div>
</div>
<DeleteConfirmationPopup
showPopup={showDeleteConfirmation}
{destination}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
<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;
position: relative;
}
.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);
position: relative;
}
.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);
}
.delete-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: rgba(240, 240, 240, 0.8);
border: none;
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--gray-600);
transition: all 0.2s ease;
}
.delete-btn:hover {
background-color: var(--memory-50);
color: var(--memory-600);
}
</style>

View File

@ -0,0 +1,64 @@
<script lang="ts">
import Button from './Button.svelte';
export let showPopup = false;
export let onConfirm: () => void;
export let onCancel: () => void;
</script>
{#if showPopup}
<div class="popup-overlay">
<div class="popup">
<h2>Turn into Itinerary</h2>
<p>Do you want to assign the places in Places To Visit into your Itinerary?</p>
<div class="buttons">
<Button text="Yes" type="single" onClick={onConfirm} />
<Button text="No" type="full-gray" onClick={onCancel} />
</div>
</div>
</div>
{/if}
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.popup {
background: white;
padding: 2rem;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--gray-900);
}
p {
margin: 1rem 0 1.5rem;
color: var(--gray-600);
line-height: 1.5;
}
.buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@ -0,0 +1,191 @@
<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';
import { ref, get } from 'firebase/database';
import { db } from '../../firebase';
let mapContainer: HTMLDivElement;
interface TripLocation {
name: string;
location: {
lat: number;
lng: number;
};
}
async function getPastTripLocations(): Promise<TripLocation[]> {
try {
const tripsRef = ref(db, 'trips');
const snapshot = await get(tripsRef);
if (!snapshot.exists()) return [];
// Get today's date at midnight for comparison
const today = new Date();
today.setHours(0, 0, 0, 0);
// Create a Set to store unique locations
const uniqueLocations = new Map<string, TripLocation>();
// Filter past trips and extract unique destinations
Object.values(snapshot.val()).forEach((trip: any) => {
const endDate = new Date(trip.endDate);
if (endDate < today && trip.destination?.location) {
const locationKey = `${trip.destination.location.lat},${trip.destination.location.lng}`;
if (!uniqueLocations.has(locationKey)) {
uniqueLocations.set(locationKey, {
name: trip.destination.name,
location: trip.destination.location
});
}
}
});
return Array.from(uniqueLocations.values());
} catch (error) {
console.error('Error fetching past trips:', error);
return [];
}
}
let cleanup: (() => void) | undefined;
onMount(() => {
let mounted = true;
async function initMap() {
if (!mounted) return;
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}`)
.attr('preserveAspectRatio', 'xMidYMid meet') as d3.Selection<SVGSVGElement, unknown, null, undefined>;
// 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);
try {
// Get past trip locations
const pastLocations = await getPastTripLocations();
if (!mounted) return;
// Load world map data
const response = await fetch('https://unpkg.com/world-atlas@2/countries-110m.json');
if (!mounted) return;
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 markers for past trip locations
g.selectAll('circle')
.data(pastLocations)
.enter()
.append('circle')
.attr('cx', d => projection([d.location.lng, d.location.lat])![0])
.attr('cy', d => projection([d.location.lng, d.location.lat])![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 initializing map:', error);
}
}
initMap();
cleanup = () => {
mounted = false;
d3.select(mapContainer).selectAll('*').remove();
};
return cleanup;
});
</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>

View 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'
}
}

View File

@ -0,0 +1,250 @@
export const countryMappings: Record<string, string> = {
'Afghanistan': 'AF',
'Albania': 'AL',
'Algeria': 'DZ',
'American Samoa': 'AS',
'Andorra': 'AD',
'Angola': 'AO',
'Anguilla': 'AI',
'Antarctica': 'AQ',
'Antigua and Barbuda': 'AG',
'Argentina': 'AR',
'Armenia': 'AM',
'Aruba': 'AW',
'Australia': 'AU',
'Austria': 'AT',
'Azerbaijan': 'AZ',
'Bahamas': 'BS',
'Bahrain': 'BH',
'Bangladesh': 'BD',
'Barbados': 'BB',
'Belarus': 'BY',
'Belgium': 'BE',
'Belize': 'BZ',
'Benin': 'BJ',
'Bermuda': 'BM',
'Bhutan': 'BT',
'Bolivia': 'BO',
'Bonaire, Sint Eustatius and Saba': 'BQ',
'Bosnia and Herzegovina': 'BA',
'Botswana': 'BW',
'Bouvet Island': 'BV',
'Brazil': 'BR',
'British Indian Ocean Territory': 'IO',
'Brunei Darussalam': 'BN',
'Bulgaria': 'BG',
'Burkina Faso': 'BF',
'Burundi': 'BI',
'Cambodia': 'KH',
'Cameroon': 'CM',
'Canada': 'CA',
'Cape Verde': 'CV',
'Cayman Islands': 'KY',
'Central African Republic': 'CF',
'Chad': 'TD',
'Chile': 'CL',
'China': 'CN',
'Christmas Island': 'CX',
'Cocos (Keeling) Islands': 'CC',
'Colombia': 'CO',
'Comoros': 'KM',
'Congo': 'CG',
'Congo, the Democratic Republic of the': 'CD',
'Cook Islands': 'CK',
'Costa Rica': 'CR',
'Croatia': 'HR',
'Cuba': 'CU',
'Curaçao': 'CW',
'Cyprus': 'CY',
'Czech Republic': 'CZ',
"Côte d'Ivoire": 'CI',
'Denmark': 'DK',
'Djibouti': 'DJ',
'Dominica': 'DM',
'Dominican Republic': 'DO',
'Ecuador': 'EC',
'Egypt': 'EG',
'El Salvador': 'SV',
'Equatorial Guinea': 'GQ',
'Eritrea': 'ER',
'Estonia': 'EE',
'Ethiopia': 'ET',
'Falkland Islands (Malvinas)': 'FK',
'Faroe Islands': 'FO',
'Fiji': 'FJ',
'Finland': 'FI',
'France': 'FR',
'French Guiana': 'GF',
'French Polynesia': 'PF',
'French Southern Territories': 'TF',
'Gabon': 'GA',
'Gambia': 'GM',
'Georgia': 'GE',
'Germany': 'DE',
'Ghana': 'GH',
'Gibraltar': 'GI',
'Greece': 'GR',
'Greenland': 'GL',
'Grenada': 'GD',
'Guadeloupe': 'GP',
'Guam': 'GU',
'Guatemala': 'GT',
'Guernsey': 'GG',
'Guinea': 'GN',
'Guinea-Bissau': 'GW',
'Guyana': 'GY',
'Haiti': 'HT',
'Heard Island and McDonald Islands': 'HM',
'Holy See (Vatican City State)': 'VA',
'Honduras': 'HN',
'Hong Kong': 'HK',
'Hungary': 'HU',
'Iceland': 'IS',
'India': 'IN',
'Indonesia': 'ID',
'Iran': 'IR',
'Iraq': 'IQ',
'Ireland': 'IE',
'Isle of Man': 'IM',
'Italy': 'IT',
'Jamaica': 'JM',
'Japan': 'JP',
'Jersey': 'JE',
'Jordan': 'JO',
'Kazakhstan': 'KZ',
'Kenya': 'KE',
'Kiribati': 'KI',
'North Korea': 'KP',
'South Korea': 'KR',
'Kuwait': 'KW',
'Kyrgyzstan': 'KG',
"Laos": 'LA',
'Latvia': 'LV',
'Lebanon': 'LB',
'Lesotho': 'LS',
'Liberia': 'LR',
'Libya': 'LY',
'Liechtenstein': 'LI',
'Lithuania': 'LT',
'Luxembourg': 'LU',
'Macao': 'MO',
'Macedonia, the former Yugoslav Republic of': 'MK',
'Madagascar': 'MG',
'Malawi': 'MW',
'Malaysia': 'MY',
'Maldives': 'MV',
'Mali': 'ML',
'Malta': 'MT',
'Marshall Islands': 'MH',
'Martinique': 'MQ',
'Mauritania': 'MR',
'Mauritius': 'MU',
'Mayotte': 'YT',
'Mexico': 'MX',
'Micronesia': 'FM',
'Moldova, Republic of': 'MD',
'Monaco': 'MC',
'Mongolia': 'MN',
'Montenegro': 'ME',
'Montserrat': 'MS',
'Morocco': 'MA',
'Mozambique': 'MZ',
'Myanmar': 'MM',
'Namibia': 'NA',
'Nauru': 'NR',
'Nepal': 'NP',
'Netherlands': 'NL',
'New Caledonia': 'NC',
'New Zealand': 'NZ',
'Nicaragua': 'NI',
'Niger': 'NE',
'Nigeria': 'NG',
'Niue': 'NU',
'Norfolk Island': 'NF',
'Northern Mariana Islands': 'MP',
'Norway': 'NO',
'Oman': 'OM',
'Pakistan': 'PK',
'Palau': 'PW',
'Palestine, State of': 'PS',
'Panama': 'PA',
'Papua New Guinea': 'PG',
'Paraguay': 'PY',
'Peru': 'PE',
'Philippines': 'PH',
'Pitcairn': 'PN',
'Poland': 'PL',
'Portugal': 'PT',
'Puerto Rico': 'PR',
'Qatar': 'QA',
'Romania': 'RO',
'Russian Federation': 'RU',
'Rwanda': 'RW',
'Réunion': 'RE',
'Saint Barthélemy': 'BL',
'Saint Helena, Ascension and Tristan da Cunha': 'SH',
'Saint Kitts and Nevis': 'KN',
'Saint Lucia': 'LC',
'Saint Martin (French part)': 'MF',
'Saint Pierre and Miquelon': 'PM',
'Saint Vincent and the Grenadines': 'VC',
'Samoa': 'WS',
'San Marino': 'SM',
'Sao Tome and Principe': 'ST',
'Saudi Arabia': 'SA',
'Senegal': 'SN',
'Serbia': 'RS',
'Seychelles': 'SC',
'Sierra Leone': 'SL',
'Singapore': 'SG',
'Sint Maarten (Dutch part)': 'SX',
'Slovakia': 'SK',
'Slovenia': 'SI',
'Solomon Islands': 'SB',
'Somalia': 'SO',
'South Africa': 'ZA',
'South Georgia and the South Sandwich Islands': 'GS',
'South Sudan': 'SS',
'Spain': 'ES',
'Sri Lanka': 'LK',
'Sudan': 'SD',
'Suriname': 'SR',
'Svalbard and Jan Mayen': 'SJ',
'Swaziland': 'SZ',
'Sweden': 'SE',
'Switzerland': 'CH',
'Syrian Arab Republic': 'SY',
'Taiwan': 'TW',
'Tajikistan': 'TJ',
'Tanzania, United Republic of': 'TZ',
'Thailand': 'TH',
'Timor-Leste': 'TL',
'Togo': 'TG',
'Tokelau': 'TK',
'Tonga': 'TO',
'Trinidad and Tobago': 'TT',
'Tunisia': 'TN',
'Turkey': 'TR',
'Turkmenistan': 'TM',
'Turks and Caicos Islands': 'TC',
'Tuvalu': 'TV',
'Uganda': 'UG',
'Ukraine': 'UA',
'United Arab Emirates': 'AE',
'United Kingdom': 'GB',
'United States': 'US',
'United States Minor Outlying Islands': 'UM',
'Uruguay': 'UY',
'Uzbekistan': 'UZ',
'Vanuatu': 'VU',
'Venezuela': 'VE',
'Vietnam': 'VN',
'Virgin Islands, British': 'VG',
'Virgin Islands, U.S.': 'VI',
'Wallis and Futuna': 'WF',
'Western Sahara': 'EH',
'Yemen': 'YE',
'Zambia': 'ZM',
'Zimbabwe': 'ZW',
'Åland Islands': 'AX'
}

View File

@ -1,2 +1,56 @@
<h1>Welcome to SvelteKit</h1> <script>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> 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="Planner" />
<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} />
</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>

View File

@ -0,0 +1,791 @@
<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 { ref, onValue, update, get } from 'firebase/database';
import { db } from '../../../firebase';
import { countryMappings } from '$lib/constants/CountryMappings';
import { Colors } from '$lib/constants/Colors';
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';
import PastTripsPanel from '$lib/components/PastTripsPanel.svelte';
import { getPlaceRecommendations, distributePlacesToItinerary } from '../../../services/openai';
import RecommendationPopup from '$lib/components/RecommendationPopup.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
import TurnIntoItineraryPopup from '$lib/components/TurnIntoItineraryPopup.svelte';
let tripData: any = null;
let tripDates: string[] = [];
let tid: string;
let countryCode = 'tw'; // country code to restrict the autocomplete search
let showTurnIntoItineraryPopup = false;
let isDistributingPlaces = false;
// the place data structure saved in the database
interface Place {
name: string;
desc?: string;
image?: string;
time?: string;
geometry?: {
lat: number;
lng: number;
};
}
interface DatePlaces {
placesPlanned: Place[];
}
let placesPlanned: Record<string, DatePlaces> = {};
/**
* Convert date format from DD/MM/YYYY to DD-MM-YYYY
*
* @param date the date to be converted
*/
function convertDateFormat(date: string): string {
return date.replace(/\//g, '-');
}
const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY;
let mapContainer: HTMLDivElement;
let expandedDates: Record<string, boolean> = {};
let map: google.maps.Map | null = null;
let markers: google.maps.marker.AdvancedMarkerElement[] = [];
let showPastTrips = false;
let showRecommendationPopup = false;
let isGeneratingRecommendations = false;
let pastTripsData: any[] = [];
/**
* Check if there are any past trips to the same destination
* and fetch their places data
*/
async function checkPastTrips(destinationName: string): Promise<boolean> {
try {
const tripsRef = ref(db, 'trips');
const snapshot = await get(tripsRef);
if (!snapshot.exists()) return false;
// Get today's date at midnight for comparison
const today = new Date();
today.setHours(0, 0, 0, 0);
// Filter past trips for this destination
pastTripsData = Object.entries(snapshot.val())
.map(([tripId, data]) => ({
tid: tripId,
...data as any
}))
.filter(trip => {
const endDate = new Date(trip.endDate);
return trip.tid !== tid && // not current trip
endDate < today && // trip is in the past
trip.destination.name === destinationName;
})
.sort((a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime()); // Most recent first
return pastTripsData.length > 0;
} catch (error) {
console.error('Error checking past trips:', error);
return false;
}
}
/**
* Get all places from past trips to this destination
*/
function getPastPlaces(): string[] {
const places = new Set<string>();
pastTripsData.forEach(trip => {
// Add places from placesToVisit
if (trip.placesToVisit) {
trip.placesToVisit.forEach((place: any) => places.add(place.name));
}
// Add places from itineraryDate
if (trip.itineraryDate) {
Object.values(trip.itineraryDate).forEach((dateData: any) => {
dateData.placesPlanned?.forEach((place: any) => places.add(place.name));
});
}
});
return Array.from(places);
}
// When tripData is loaded, check for past trips
$: if (tripData?.destination?.name) {
checkPastTrips(tripData.destination.name);
}
/**
* Get the ISO 3166-1 alpha-2 country code from /constants/CountryMappings.ts
*
* @param formattedAddress the address of the destination
* @returns the country code of the input
*/
function getCountryCode(formattedAddress: string): string {
// get the country from the last part of formatted address
const parts = formattedAddress.split(',');
const country = parts[parts.length - 1].trim();
// check if the mapping is available in /constants/CountryMappings.ts
if (countryMappings[country]) {
return countryMappings[country];
}
// if no mapping found, convert to lowercase and take first two letters
// might not always be correct
return country.toLowerCase().slice(0, 2);
}
function clearMarkers() {
if (markers.length > 0) {
markers.forEach(marker => marker.map = null);
markers = [];
}
}
async function updateMapMarkers() {
if (!map) return;
// clear existing markers
clearMarkers();
const { AdvancedMarkerElement, PinElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary;
// add markers for placesToVisit (red color)
placesToVisit.forEach(place => {
if (place.geometry?.lat && place.geometry?.lng) {
const pin = new PinElement({
background: Colors.memory.med400,
borderColor: Colors.memory.dark700,
glyphColor: Colors.white,
});
const marker = new AdvancedMarkerElement({
map,
position: { lat: place.geometry.lat, lng: place.geometry.lng },
title: place.name,
content: pin.element
});
markers.push(marker);
}
});
// add markers for placesPlanned (blue color)
Object.values(placesPlanned).forEach(dateData => {
if (!dateData?.placesPlanned) return;
dateData.placesPlanned.forEach(place => {
if (place.geometry?.lat && place.geometry?.lng) {
const pin = new PinElement({
background: Colors.planner.med400,
borderColor: Colors.planner.dark700,
glyphColor: Colors.white,
});
const marker = new AdvancedMarkerElement({
map,
position: { lat: place.geometry.lat, lng: place.geometry.lng },
title: place.name,
content: pin.element
});
markers.push(marker);
}
});
});
}
// Update markers whenever places change
$: {
if (placesToVisit || placesPlanned) {
updateMapMarkers();
}
}
onMount(async () => {
if (!browser) return;
if (!GOOGLE_PLACES_API_KEY) {
console.error('Google Places API key is missing');
return;
}
// Get the trip ID from the URL
tid = page.params.tid;
const loader = new Loader({
apiKey: GOOGLE_PLACES_API_KEY,
version: "weekly",
libraries: ["places"],
language: 'en'
});
try {
const { Map } = await loader.importLibrary("maps");
// fetch trip data and initialize map when data is ready
const tripRef = ref(db, `trips/${tid}`);
onValue(tripRef, (snapshot) => {
tripData = snapshot.val();
if (tripData) {
// update country code based on destination
if (tripData.destination?.formatted_address) {
countryCode = getCountryCode(tripData.destination.formatted_address);
}
// generate array of dates between start and end date
const start = new Date(tripData.startDate);
const end = new Date(tripData.endDate);
const dates = [];
for (let date = new Date(start); date <= end; date.setDate(date.getDate() + 1)) {
dates.push(date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }));
}
tripDates = dates;
// initialize expanded states for dates
expandedDates = Object.fromEntries(dates.map(date => [date, true]));
// initialize placesToVisit from database or empty array
placesToVisit = tripData.placesToVisit || [];
// initialize placesPlanned from database or empty object
placesPlanned = {};
if (tripData.itineraryDate) {
// convert keys from DD/MM/YYYY to DD-MM-YYYY if needed
Object.entries(tripData.itineraryDate).forEach(([key, value]) => {
const formattedKey = key.includes('/') ? convertDateFormat(key) : key;
placesPlanned[formattedKey] = value as DatePlaces;
});
}
// initialize or update the map
if (mapContainer && tripData.destination?.location) {
if (!map) {
map = new Map(mapContainer, {
center: tripData.destination.location,
zoom: 10,
mapId: 'ITINERARY_MAP_ID'
});
} else {
map.setCenter(tripData.destination.location);
}
}
}
});
} catch (error) {
console.error('Error loading Google Maps:', error);
}
});
let expandedSections = {
explore: true,
places_to_visit: true,
itinerary: true
};
let recommendedPlaces = [
{ name: "Place name" },
{ name: "Place name" },
{ name: "Place name" }
];
let placesToVisit: any[] = [];
async function handleDeletePlace(index: number) {
const newPlacesToVisit = placesToVisit.filter((_, i) => i !== index);
try {
// Update the database
await update(ref(db, `trips/${tid}`), {
placesToVisit: newPlacesToVisit
});
// Update local state
placesToVisit = newPlacesToVisit;
} catch (error) {
console.error('Error deleting place:', error);
}
}
function toggleSection(section: keyof typeof expandedSections) {
expandedSections[section] = !expandedSections[section];
}
function handleBack() {
goto('/trips');
}
function handlePastTrip() {
showPastTrips = !showPastTrips;
}
async function handlePlaceSelected(place: google.maps.places.PlaceResult) {
const newPlace = {
name: place.name || 'Unknown Place',
desc: place.formatted_address || '',
image: (place as any).photoUrl || '/placeholder.jpeg',
geometry: place.geometry?.location ? {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng()
} : undefined
};
const updatedPlaces = [...placesToVisit, newPlace];
try {
// update the database
await update(ref(db, `trips/${tid}`), {
placesToVisit: updatedPlaces
});
// update local state
placesToVisit = updatedPlaces;
} catch (error) {
console.error('Error adding place:', error);
}
}
async function handleRecommendPlaces() {
if (!tripData?.destination?.name) return;
console.log("recommend places");
// Check for past trips
const hasPastTrips = await checkPastTrips(tripData.destination.name);
if (hasPastTrips) {
console.log("has past trips");
showRecommendationPopup = true;
} else {
// If no past trips, proceed with getting new recommendations
await getRecommendations('new');
}
}
async function handleRecommendationSelect(type: 'new' | 'old' | 'mix') {
showRecommendationPopup = false;
await getRecommendations(type);
}
function handleRecommendationCancel() {
showRecommendationPopup = false;
}
async function getRecommendations(recommendationType: 'new' | 'old' | 'mix') {
isGeneratingRecommendations = true;
try {
// Get current places from both placesToVisit and placesPlanned
const currentPlaces = [
...placesToVisit.map(p => p.name),
...Object.values(placesPlanned).flatMap(date =>
date.placesPlanned?.map(p => p.name) || []
)
];
// Get past places from previous trips
const pastPlaces = getPastPlaces();
// Get recommendations from OpenAI
const recommendations = await getPlaceRecommendations(
tripData.destination.name,
currentPlaces,
pastPlaces,
recommendationType
);
// For each recommendation, search Google Places API for details
const placesService = new google.maps.places.PlacesService(map!);
for (const rec of recommendations) {
const request = {
query: `${rec.name} ${tripData.destination.name}`,
fields: ['name', 'formatted_address', 'photos', 'place_id', 'geometry']
};
try {
const results = await new Promise<google.maps.places.PlaceResult[]>((resolve, reject) => {
placesService.textSearch(request, (results, status) => {
if (status === google.maps.places.PlacesServiceStatus.OK && results) {
resolve(results);
} else {
reject(new Error(`Places API error: ${status}`));
}
});
});
if (results.length > 0) {
const place = results[0];
const photoUrl = place.photos?.[0]?.getUrl();
const newPlace = {
name: place.name || rec.name,
desc: place.formatted_address || '',
image: photoUrl || '/placeholder.jpeg',
geometry: place.geometry?.location ? {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng()
} : undefined
};
// Add to placesToVisit
placesToVisit = [...placesToVisit, newPlace];
}
} catch (error) {
console.error(`Error fetching place details for ${rec.name}:`, error);
}
}
// Update the database with new places
await update(ref(db, `trips/${tid}`), {
placesToVisit
});
} catch (error) {
console.error('Error getting recommendations:', error);
alert('Failed to get recommendations. Please try again.');
} finally {
isGeneratingRecommendations = false;
}
}
async function handleTurnIntoItinerary() {
showTurnIntoItineraryPopup = true;
}
async function handleConfirmTurnIntoItinerary() {
showTurnIntoItineraryPopup = false;
isDistributingPlaces = true;
try {
// Convert dates to DD-MM-YYYY format
const formattedDates = tripDates.map(date => convertDateFormat(date));
// Distribute places using OpenAI, passing the existing places
const distribution = await distributePlacesToItinerary(
placesToVisit,
formattedDates,
tripData.destination.name,
placesPlanned
);
// Update the database with the new distribution
await update(ref(db, `trips/${tid}/itineraryDate`), distribution);
// Update local state
placesPlanned = distribution;
// Clear places to visit
await update(ref(db, `trips/${tid}`), {
placesToVisit: []
});
placesToVisit = [];
} catch (error) {
console.error('Error turning into itinerary:', error);
alert('Failed to distribute places. Please try again.');
} finally {
isDistributingPlaces = false;
}
}
function handleCancelTurnIntoItinerary() {
showTurnIntoItineraryPopup = false;
}
async function handlePlacePlanned(date: string, places: Place[]) {
const formattedDate = convertDateFormat(date);
try {
// Update the database
await update(ref(db, `trips/${tid}/itineraryDate/${formattedDate}`), {
placesPlanned: places
});
// Update local state
placesPlanned = {
...placesPlanned,
[formattedDate]: {
placesPlanned: places
}
};
} catch (error) {
console.error('Error updating places planned:', error);
}
}
</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 {tripData?.destination?.name || 'Loading...'}</h1>
<p class="date">{tripData?.startDate ? new Date(tripData.startDate).toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }) : ''} - {tripData?.endDate ? new Date(tripData.endDate).toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }) : ''}</p>
</div>
<div class="tripmates">
<ProfilePicture friends={tripData?.tripmates?.length || 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>
{#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={countryCode}
/>
<div class="places-buttons">
<Button text="Recommend Places" type="blue" onClick={handleRecommendPlaces} />
<Button text="Turn into Itinerary" type="blue" onClick={handleTurnIntoItinerary} />
</div>
</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={date}
isExpanded={expandedDates[date]}
places={placesPlanned[convertDateFormat(date)]?.placesPlanned || []}
onPlacesUpdate={(places) => handlePlacePlanned(date, places)}
countryCode={countryCode}
/>
{/each}
</div>
{/if}
</section>
</div>
</div>
<div class="map-section">
<div class="map-container" bind:this={mapContainer}></div>
<BottomBar title="Past Trips" desc="Click to view all past trips to {tripData?.destination?.name}" onClick={handlePastTrip} />
<PastTripsPanel
showPanel={showPastTrips}
destination={tripData?.destination?.name || ''}
pastTrips={pastTripsData}
onClose={() => showPastTrips = false}
/>
</div>
<RecommendationPopup
showPopup={showRecommendationPopup}
destination={tripData?.destination?.name || ''}
onSelect={handleRecommendationSelect}
onCancel={handleRecommendationCancel}
/>
<TurnIntoItineraryPopup
showPopup={showTurnIntoItineraryPopup}
onConfirm={handleConfirmTurnIntoItinerary}
onCancel={handleCancelTurnIntoItinerary}
/>
<LoadingOverlay
show={isGeneratingRecommendations || isDistributingPlaces}
message={isDistributingPlaces ? "Distributing Places into Itinerary" : "Generating Recommended Places"}
/>
</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;
position: relative;
overflow: hidden;
}
.map-container {
flex: 1;
position: relative;
}
header {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.75rem;
padding: 0 2rem 1.5rem 0.75rem;
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 0.75rem;
color: var(--gray-400);
border-radius: 50%;
transition: all 0.2s ease;
}
.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;
}
.places-buttons {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
}
</style>

View File

@ -0,0 +1,60 @@
<script>
import '../../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>

View File

@ -0,0 +1,124 @@
<script lang="ts">
import '../../app.css';
import MemoryCard from '$lib/components/MemoryCard.svelte';
import Button from '$lib/components/Button.svelte';
import NewMemoryPopup from '$lib/components/NewMemoryPopup.svelte';
import Nav from '$lib/components/Nav.svelte';
interface Trip {
destination: string;
startDate: string;
endDate: string;
imageUrl: string;
}
let showNewMemoryPopup = false;
let contentContainer: HTMLElement;
// Sample data, replace with actual data later
const sample_memories = {
destination: "Taiwan",
startDate: "04.27.2025",
endDate: "04.30.2025",
imageUrl: ""
}
let pastMemories = Array(3).fill(sample_memories);
function handleNewMemory() {
showNewMemoryPopup = true;
}
</script>
<main>
<Nav activeTab="MyMemory" darkMode={true}/>
<div class="content" bind:this={contentContainer}>
<div class="header">
<h1>My Memories</h1>
</div>
<div class="memories-container">
{#if pastMemories.length === 0}
<div class="empty-state">
<p>There is no past trip</p>
</div>
{:else}
<div class="memories-grid">
{#each pastMemories as memory}
<MemoryCard {...memory} />
{/each}
</div>
{/if}
</div>
<div class="floating-button">
<Button text="+ Add a new memory" type="memory" onClick={handleNewMemory} />
</div>
</div>
<NewMemoryPopup bind:showPopup={showNewMemoryPopup} />
</main>
<style>
main {
height: 100vh;
background-color: var(--black);
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(--black);
}
.header h1 {
font-size: 2rem;
font-weight: 600;
margin: 0;
color: var(--white);
}
.memories-container {
flex: 1;
padding: 0 1rem;
display: flex;
flex-direction: column;
}
.memories-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>

View File

@ -0,0 +1,255 @@
<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';
import { onMount } from 'svelte';
import { ref, onValue } from 'firebase/database';
import { db } from '../../firebase';
interface Trip {
tid: string;
destination: {
name: string;
photo: string;
formatted_address: string;
location: {
lat: number;
lng: number;
}
};
startDate: string;
endDate: string;
tripmates: string[];
created_at: string;
}
let activeTab = "Ongoing Trips";
let showNewTripPopup = false;
let contentContainer: HTMLElement;
let ongoingTrips: Trip[] = [];
let pastTrips: Trip[] = [];
onMount(() => {
// reference to the trips node
const tripsRef = ref(db, 'trips');
// listen for changes in the trips data
onValue(tripsRef, (snapshot) => {
const trips: Trip[] = [];
snapshot.forEach((childSnapshot) => {
trips.push({
tid: childSnapshot.key,
...childSnapshot.val()
});
});
// get today's date at midnight for comparison
const today = new Date();
today.setHours(0, 0, 0, 0);
// filter trips based on end date
// end date > today = pastTrips
ongoingTrips = trips.filter(trip => {
const endDate = new Date(trip.endDate);
return endDate >= today;
}).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
pastTrips = trips.filter(trip => {
const endDate = new Date(trip.endDate);
return endDate < today;
}).sort((a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime()); // sort past trips by most recent first
});
});
function handleNewTrip() {
showNewTripPopup = true;
}
function handleTabChange(tab: string) {
activeTab = tab;
contentContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
}
</script>
<main>
<Nav activeTab="MyTrip" />
<div class="content" bind:this={contentContainer}>
<div class="header">
<h1>My 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
destination={trip.destination.name}
startDate={new Date(trip.startDate).toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}
endDate={new Date(trip.endDate).toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}
image={trip.destination.photo}
tid={trip.tid}
/>
{/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
destination={trip.destination.name}
startDate={new Date(trip.startDate).toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}
endDate={new Date(trip.endDate).toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}
image={trip.destination.photo}
tid={trip.tid}
/>
{/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} />
</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>

View File

@ -0,0 +1,115 @@
<script>
import '../../app.css';
import Nav from '$lib/components/Nav.svelte';
import Button from '$lib/components/Button.svelte';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { getDatabase, ref, get } from 'firebase/database';
import { db } from '../../firebase';
let memoryId = '';
let memory = null;
$: {
const q = $page.url.searchParams;
memoryId = q.get('id') ?? '';
}
onMount(async () => {
if (memoryId) {
const snapshot = await get(ref(db, `memories/${memoryId}`));
if (snapshot.exists()) {
memory = snapshot.val();
} else {
console.error('No memory found');
}
}
});
let gradientColors = ['#e74c3c', '#f1c40f', '#2ecc71', '#3498db', '#9b59b6'];
$: gradientStyle = `
conic-gradient(
${gradientColors.map((c, i) => `${c} ${i * 72}deg ${(i + 1) * 72}deg`).join(',')}
)
`;
</script>
<main>
<Nav activeTab="MyMemory" darkMode={true}/>
<div class="content">
{#if memory}
<div class="header">
<h1>{memory.location}</h1>
<p>{memory.startDate} - {memory.endDate}</p>
</div>
<div class="wheel-container">
<div class="gradient-wheel" style="background-image: {gradientStyle};"></div>
</div>
<div class="image-list">
{#each memory.images as img}
<img src={typeof img === 'string' ? img : URL.createObjectURL(img)} alt="Memory image" />
{/each}
</div>
{:else}
<p class="empty">Loading memory...</p>
{/if}
</div>
</main>
<style>
main {
height: 100vh;
background-color: var(--black);
font-family: 'Inter', sans-serif;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 2rem 1rem;
overflow-y: auto;
}
.header {
text-align: center;
color: var(--white);
margin-bottom: 2rem;
}
.gradient-wheel {
width: 300px;
height: 300px;
margin: 2rem auto;
border-radius: 50%;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}
.wheel-container {
display: flex;
justify-content: center;
}
.image-list {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.image-list img {
width: 80%;
max-width: 500px;
border-radius: 12px;
}
.empty {
color: var(--gray-400);
text-align: center;
margin-top: 4rem;
}
</style>

190
src/services/openai.ts Normal file
View File

@ -0,0 +1,190 @@
import OpenAI from 'openai';
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY;
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
dangerouslyAllowBrowser: true // might be dangerous
});
interface PlaceRecommendation {
name: string;
description: string;
}
export async function getPlaceRecommendations(
destination: string,
currentPlaces: string[],
pastPlaces: string[] = [],
recommendationType: 'new' | 'old' | 'mix'
): Promise<PlaceRecommendation[]> {
let prompt = `You are a travel planner expert. I need recommendations for places to visit in ${destination}.
Current itinerary includes: ${currentPlaces.join(', ')}
`;
if (pastPlaces.length > 0) {
prompt += `From my previous trip, I visited: ${pastPlaces.join(', ')}`;
// Adjust recommendation type based on user preference
let recommendationGuidance = '';
switch (recommendationType) {
case 'new':
recommendationGuidance = 'Please recommend 5 new places I haven\'t visited before.';
break;
case 'old':
recommendationGuidance = 'Please recommend 5 places from my previous visits that are worth revisiting. If there are less than 5 places, recommend all of them.';
break;
case 'mix':
recommendationGuidance = 'Please recommend a mix of 5 places, including both new locations and some worth revisiting from my previous trip.';
break;
}
prompt += recommendationGuidance;
}
else {
prompt += `This is my first time going to ${destination}. Please recommend 5 places I should visit.`;
}
prompt += `
Please return your answer as a JSON object with a key "recommendations", containing an array of 5 objects. Each object should have two keys: "name" and "desc".
Example format:
{
"recommendations": [
{
"name": "Place Name 1",
"desc": "Brief description of why to visit Place Name 1"
},
{
"name": "Place Name 2",
"desc": "Brief description of why to visit Place Name 2"
}
]
}
`;
console.log(`prompt: ${prompt}`);
try {
const response = await openai.chat.completions.create({
model: 'gpt-4.1-mini',
messages: [
{
role: 'system',
content: 'You are a knowledgeable travel expert providing place recommendations in JSON format.'
},
{
role: 'user',
content: prompt
}
],
temperature: 0.7,
response_format: { type: "json_object" }
});
console.log(response);
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No recommendations received');
}
console.log(`content: ${content}`);
const parsedContent = JSON.parse(content);
return parsedContent.recommendations || [];
} catch (error) {
console.error('Error getting recommendations:', error);
throw error;
}
}
export async function distributePlacesToItinerary(
places: Array<{ name: string; desc?: string; image?: string; geometry?: { lat: number; lng: number } }>,
dates: string[],
destination: string,
existingPlacesPlanned: Record<string, { placesPlanned: any[] }> = {}
): Promise<Record<string, { placesPlanned: any[] }>> {
if (!OPENAI_API_KEY) {
throw new Error('OpenAI API key is missing');
}
const placesData = places.map(place => ({
name: place.name,
location: place.geometry ? `${place.geometry.lat},${place.geometry.lng}` : 'unknown'
}));
const prompt = `You are a travel planner helping to distribute ${places.length} places across ${dates.length} days in ${destination}.
Here are the places with their coordinates: ${JSON.stringify(placesData)}.
The available dates are: ${dates.join(', ')}.
Please distribute these places into a daily itinerary following these rules:
1. Group places that are geographically close to each other on the same day to minimize travel time
2. Consider a realistic number of places per day (typically 24 places)
3. If there are more days than needed, it's okay to leave some days empty
4. Return the result as a JSON object where:
- Keys are dates in DD-MM-YYYY format
- Values are objects with a 'placesPlanned' array containing the place names (just the names as strings)
- Each place should be assigned to exactly one day
The response should be ONLY the JSON object, nothing else.`;
console.log(`prompt: ${prompt}`);
try {
const response = await openai.chat.completions.create({
model: 'gpt-4.1-mini',
messages: [
{
role: 'system',
content: 'You are a helpful travel itinerary planner.'
},
{
role: 'user',
content: prompt
}
],
temperature: 0.7,
response_format: { type: "json_object" }
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No itinerary received from OpenAI.');
}
const distribution = JSON.parse(content) as Record<string, { placesPlanned: string[] }>;
// Start with the existing places planned
const result: Record<string, { placesPlanned: any[] }> = { ...existingPlacesPlanned };
// Add new places while preserving all their properties
for (const [date, dayData] of Object.entries(distribution)) {
// Initialize the date's places array with existing places or empty array
result[date] = {
placesPlanned: [...(result[date]?.placesPlanned || [])]
};
// Add the new places with all their properties
const newPlaces = dayData.placesPlanned.map((placeName: string) => {
const originalPlace = places.find(p => p.name === placeName);
if (!originalPlace) return { name: placeName };
// Keep all original properties
return {
name: originalPlace.name,
desc: originalPlace.desc,
image: originalPlace.image,
geometry: originalPlace.geometry
};
});
// Append new places to existing ones
result[date].placesPlanned = [...result[date].placesPlanned, ...newPlaces];
}
return result;
} catch (error) {
console.error('Error distributing places:', error);
throw error;
}
}

BIN
static/placeholder.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
static/profile-pic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/user.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -2,5 +2,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit()],
ssr: {
noExternal: ['@googlemaps/js-api-loader']
}
}); });