Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
727da919a5 | ||
|
e7eaf2a592 | ||
|
f903569a60 | ||
|
38c19728e8 |
4057
frontend/package-lock.json
generated
4057
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -20,13 +20,20 @@
|
|||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.11",
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
"@types/leaflet": "^1.9.16",
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/pikaday": "^1.7.9",
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"daisyui": "^4.12.23",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
@ -43,6 +50,31 @@
|
|||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inlang/paraglide-sveltekit": "^0.15.5"
|
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
|
||||||
|
"@capacitor/cli": "^7.0.1",
|
||||||
|
"@capacitor/core": "^7.0.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@inlang/paraglide-sveltekit": "^0.15.5",
|
||||||
|
"@svelte-plugins/datepicker": "^1.0.11",
|
||||||
|
"@uppy/core": "^4.4.1",
|
||||||
|
"@uppy/dashboard": "^4.3.1",
|
||||||
|
"@uppy/svelte": "^4.3.0",
|
||||||
|
"@uwdata/vgplot": "^0.12.2",
|
||||||
|
"canvas-confetti": "^1.9.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"globe.gl": "^2.39.2",
|
||||||
|
"gsap": "^3.12.7",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"lucide-svelte": "^0.474.0",
|
||||||
|
"maplibre-gl": "^5.1.0",
|
||||||
|
"motion": "^12.0.6",
|
||||||
|
"phoenix": "^1.7.18",
|
||||||
|
"pikaday": "^1.8.2",
|
||||||
|
"sonner": "^1.7.3",
|
||||||
|
"svelte-motion": "^0.12.2",
|
||||||
|
"svelte-transitions": "^1.2.0",
|
||||||
|
"three": "^0.172.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
frontend/project.inlang/.gitignore
vendored
Normal file
1
frontend/project.inlang/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
cache
|
1
frontend/project.inlang/project_id
Normal file
1
frontend/project.inlang/project_id
Normal file
@ -0,0 +1 @@
|
|||||||
|
3dce976a8e4e53c64d644fc466bfa4deade063ca8a755bb4c9eb5ab60d38c377
|
@ -1,3 +1,12 @@
|
|||||||
@import 'tailwindcss/base';
|
@import 'tailwindcss/base';
|
||||||
@import 'tailwindcss/components';
|
@import 'tailwindcss/components';
|
||||||
@import 'tailwindcss/utilities';
|
@import 'tailwindcss/utilities';
|
||||||
|
|
||||||
|
/* Add to your global CSS */
|
||||||
|
.screen-content {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
172
frontend/src/lib/Card.svelte
Normal file
172
frontend/src/lib/Card.svelte
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { animate, spring } from "motion";
|
||||||
|
import { onMount, createEventDispatcher } from 'svelte';
|
||||||
|
import type { Profile } from "./profiles";
|
||||||
|
|
||||||
|
export let profile: Profile;
|
||||||
|
export let active = false;
|
||||||
|
|
||||||
|
let element: HTMLDivElement;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let isDragging = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let rotate = 0;
|
||||||
|
let opacity = 1;
|
||||||
|
let overlayColor = '';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
swipe: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (active) {
|
||||||
|
setupDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type the animation parameters properly
|
||||||
|
animate(
|
||||||
|
element,
|
||||||
|
{ scale: [0.9, 1] },
|
||||||
|
{ duration: 0.3, easing: 'ease-out' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup listeners on component destroy
|
||||||
|
if (active) {
|
||||||
|
element?.removeEventListener('pointerdown', startDrag);
|
||||||
|
document.removeEventListener('pointerup', endDrag);
|
||||||
|
document.removeEventListener('pointermove', updateDrag);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupDrag() {
|
||||||
|
element?.addEventListener('pointerdown', startDrag);
|
||||||
|
document.addEventListener('pointerup', endDrag);
|
||||||
|
document.addEventListener('pointermove', updateDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e: PointerEvent) {
|
||||||
|
if (!element) return;
|
||||||
|
isDragging = true;
|
||||||
|
startX = e.clientX - x;
|
||||||
|
startY = e.clientY - y;
|
||||||
|
element.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDrag(e: PointerEvent) {
|
||||||
|
if (!isDragging || !element) return;
|
||||||
|
|
||||||
|
x = e.clientX - startX;
|
||||||
|
y = e.clientY - startY;
|
||||||
|
rotate = x * 0.1;
|
||||||
|
opacity = Math.max(0.2, 1 - Math.abs(x) / 300);
|
||||||
|
|
||||||
|
overlayColor = x > 0
|
||||||
|
? `linear-gradient(90deg, rgba(34,197,94,0.2) 0%, rgba(255,255,255,0) 60%)`
|
||||||
|
: `linear-gradient(270deg, rgba(239,68,68,0.2) 0%, rgba(255,255,255,0) 60%)`;
|
||||||
|
|
||||||
|
updateElementTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateElementTransform() {
|
||||||
|
if (!element) return;
|
||||||
|
element.style.transform = `translate(${x}px, ${y}px) rotate(${rotate}deg)`;
|
||||||
|
element.style.opacity = opacity.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
if (!isDragging || !element) return;
|
||||||
|
|
||||||
|
isDragging = false;
|
||||||
|
element.style.cursor = 'grab';
|
||||||
|
|
||||||
|
const threshold = 100;
|
||||||
|
if (Math.abs(x) > threshold) {
|
||||||
|
swipe(x > 0);
|
||||||
|
} else {
|
||||||
|
resetCardPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCardPosition() {
|
||||||
|
animate(
|
||||||
|
element,
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotate: 0,
|
||||||
|
opacity: 1
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
easing: spring({
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20
|
||||||
|
} as any)
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
overlayColor = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function swipe(right: boolean) {
|
||||||
|
const direction = right ? 500 : -500;
|
||||||
|
|
||||||
|
animate(
|
||||||
|
element,
|
||||||
|
{
|
||||||
|
x: direction,
|
||||||
|
y: y + (Math.random() - 0.5) * 100,
|
||||||
|
rotate: direction / 4,
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
easing: spring(5),
|
||||||
|
onComplete: () => {
|
||||||
|
x = 0;
|
||||||
|
y = 0;
|
||||||
|
rotate = 0;
|
||||||
|
opacity = 1;
|
||||||
|
overlayColor = '';
|
||||||
|
dispatch('swipe', right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={element}
|
||||||
|
class="card w-96 bg-base-100 shadow-2xl absolute cursor-grab
|
||||||
|
transition-transform duration-200 hover:shadow-primary/20
|
||||||
|
hover:-translate-y-1"
|
||||||
|
style="transform: translate({x}px, {y}px) rotate({rotate}deg);
|
||||||
|
opacity: {opacity}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 rounded-xl"
|
||||||
|
style="background: {overlayColor}"
|
||||||
|
></div>
|
||||||
|
<figure class="h-64 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={profile.img}
|
||||||
|
alt={profile.name}
|
||||||
|
class="w-full h-full object-cover transition-transform
|
||||||
|
duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<div class="card-body bg-gradient-to-t from-base-100
|
||||||
|
via-base-100/90 to-transparent">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="card-title text-3xl font-bold drop-shadow-sm">
|
||||||
|
{profile.name}
|
||||||
|
</h2>
|
||||||
|
<div class="badge badge-primary badge-lg p-4">
|
||||||
|
{profile.age}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg text-base-content/80">{profile.bio}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
139
frontend/src/lib/Nav.svelte
Normal file
139
frontend/src/lib/Nav.svelte
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: 'home' | 'search' | 'add' | 'notifications' | 'messages';
|
||||||
|
path: string;
|
||||||
|
icon: {
|
||||||
|
active: string;
|
||||||
|
inactive: string;
|
||||||
|
};
|
||||||
|
label: string;
|
||||||
|
badge?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurable props
|
||||||
|
export let showLabels = true;
|
||||||
|
export let animate = true;
|
||||||
|
export let darkMode = false;
|
||||||
|
|
||||||
|
// Icons with distinct active/inactive states
|
||||||
|
const icons = {
|
||||||
|
home: {
|
||||||
|
active: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||||
|
inactive: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
active: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||||
|
inactive: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
active: 'M12 4v16m8-8H4',
|
||||||
|
inactive: 'M12 4v16m8-8H4',
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
active: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
|
||||||
|
inactive: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
active: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8m-18 8h18a2 2 0 002-2V6a2 2 0 00-2-2H3a2 2 0 00-2 2v8a2 2 0 002 2z',
|
||||||
|
inactive: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8m-18 8h18a2 2 0 002-2V6a2 2 0 00-2-2H3a2 2 0 00-2 2v8a2 2 0 002 2z',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ id: 'home', path: '/home', icon: icons.home, label: 'Home' },
|
||||||
|
{ id: 'search', path: '/search', icon: icons.search, label: 'Search' },
|
||||||
|
{ id: 'add', path: '/add', icon: icons.add, label: '+' },
|
||||||
|
{ id: 'notifications', path: '/notifications', icon: icons.notifications, label: 'Notifications', badge: 3 },
|
||||||
|
{ id: 'messages', path: '/dm', icon: icons.messages, label: 'DM' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if current route matches
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
return $page.url.pathname === path;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
class="btm-nav fixed bottom-0 z-50 w-full border-t {darkMode ? 'border-gray-700 bg-gray-900' : 'border-base-200 bg-base-100'}"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
{#each navItems as tab (tab.id)}
|
||||||
|
<a
|
||||||
|
href={tab.path}
|
||||||
|
class="btm-nav-btn relative flex flex-col items-center justify-center gap-1 p-2 {isActive(tab.path) ? 'active text-primary' : 'text-base-content'}"
|
||||||
|
aria-label={tab.label}
|
||||||
|
aria-current={isActive(tab.path) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 transition-all duration-200 {animate && isActive(tab.path) ? 'scale-110' : 'scale-105'}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width={isActive(tab.path) ? 2 : 1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d={isActive(tab.path) ? tab.icon.active : tab.icon.inactive}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{#if tab.badge}
|
||||||
|
<span
|
||||||
|
class="absolute -right-2 -top-2 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs text-white"
|
||||||
|
transition:fade
|
||||||
|
>
|
||||||
|
{tab.badge}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showLabels}
|
||||||
|
<span
|
||||||
|
class="btm-nav-label text-xs font-medium transition-opacity {isActive(tab.path) ? 'opacity-100' : 'opacity-0'}"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btm-nav {
|
||||||
|
height: 64px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btm-nav-btn {
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .btm-nav-btn.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 2px;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* @media (min-width: 640px) {
|
||||||
|
.btm-nav {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
</style>
|
BIN
frontend/src/lib/assets/h.png
Normal file
BIN
frontend/src/lib/assets/h.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 217 KiB |
24
frontend/src/lib/profiles.ts
Normal file
24
frontend/src/lib/profiles.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export interface Profile {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
bio: string;
|
||||||
|
img: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profiles: Profile[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Sarah",
|
||||||
|
age: 28,
|
||||||
|
bio: "Adventure seeker | Coffee lover",
|
||||||
|
img: "https://randomuser.me/api/portraits/women/1.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Mike",
|
||||||
|
age: 32,
|
||||||
|
bio: "Hiker | Guitar player",
|
||||||
|
img: "https://randomuser.me/api/portraits/men/1.jpg"
|
||||||
|
}
|
||||||
|
];
|
30
frontend/src/lib/types.ts
Normal file
30
frontend/src/lib/types.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// types.ts
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: number;
|
||||||
|
user: User;
|
||||||
|
lastMessage: string;
|
||||||
|
timestamp: string;
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
sender: 'me' | number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number;
|
||||||
|
type: 'message' | 'alert' | 'system';
|
||||||
|
content: string;
|
||||||
|
read: boolean;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
34
frontend/src/routes/(authed)/+layout.svelte
Normal file
34
frontend/src/routes/(authed)/+layout.svelte
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { i18n } from '$lib/i18n';
|
||||||
|
import Nav from '$lib/Nav.svelte';
|
||||||
|
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<ParaglideJS {i18n}>
|
||||||
|
<div class="min-h-full fixed w-full bg-gradient-to-b from-sky-100 to-emerald-50 z-30">
|
||||||
|
<header class="flex items-center justify-between mb-6 px-6 pt-6 sticky top-0 w-full z-10" in:fade>
|
||||||
|
<button class="btn btn-circle btn-primary" onclick={() => {window.history.back()}}>❮</button>
|
||||||
|
<h1 class="text-2xl font-bold text-sky-800 drop-shadow-sm">Boundless</h1>
|
||||||
|
<div class="w-8"></div>
|
||||||
|
</header>
|
||||||
|
<div class="fillscreen">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
<Nav
|
||||||
|
showLabels={false}
|
||||||
|
animate={true}
|
||||||
|
darkMode={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ParaglideJS>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fillscreen{
|
||||||
|
height: calc(100svh - 64px);
|
||||||
|
}
|
||||||
|
</style>
|
332
frontend/src/routes/(authed)/add/+page.svelte
Normal file
332
frontend/src/routes/(authed)/add/+page.svelte
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Traveler {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
location: string;
|
||||||
|
interests: string[];
|
||||||
|
currentMission?: string;
|
||||||
|
impactPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Serendipity {
|
||||||
|
id: string;
|
||||||
|
type: 'local_tip' | 'chance_meetup' | 'hidden_gem' | 'help_needed';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
location: string;
|
||||||
|
creator: Traveler;
|
||||||
|
timePosted: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JourneyCircle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
theme: string;
|
||||||
|
members: Traveler[];
|
||||||
|
nextDestination?: string;
|
||||||
|
impactGoal: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
let activeTab: 'magic' | 'allies' | 'impact' = 'magic';
|
||||||
|
let globeInstance: any;
|
||||||
|
let globeContainer: HTMLElement;
|
||||||
|
|
||||||
|
// Dummy data
|
||||||
|
const nearbyTravelers: Traveler[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Sofia Chen',
|
||||||
|
avatar:
|
||||||
|
'https://w7.pngwing.com/pngs/48/259/png-transparent-profile-man-male-photo-face-portrait-illustration-vector-people-blue.png',
|
||||||
|
location: 'Currently in: Kyoto, Japan',
|
||||||
|
interests: ['Cultural Preservation', 'Street Photography', 'Local Markets'],
|
||||||
|
currentMission: 'Documenting traditional craftspeople',
|
||||||
|
impactPoints: 2840
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Marco Rivera',
|
||||||
|
avatar:
|
||||||
|
'https://w7.pngwing.com/pngs/48/259/png-transparent-profile-man-male-photo-face-portrait-illustration-vector-people-blue.png',
|
||||||
|
location: 'Currently in: Kyoto, Japan',
|
||||||
|
interests: ['Sustainable Travel', 'Language Exchange', 'Hidden Cafes'],
|
||||||
|
currentMission: 'Teaching free English classes',
|
||||||
|
impactPoints: 1920
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const serendipityFeeds: Serendipity[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'hidden_gem',
|
||||||
|
title: 'Secret Bamboo Grove Meditation',
|
||||||
|
description:
|
||||||
|
'Found a local monk who offers sunrise meditation sessions in a hidden bamboo grove. Space for 2 more people tomorrow!',
|
||||||
|
location: 'Arashiyama, Kyoto',
|
||||||
|
creator: nearbyTravelers[0],
|
||||||
|
timePosted: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'help_needed',
|
||||||
|
title: 'Translation Help at Local Market',
|
||||||
|
description:
|
||||||
|
'Elderly shop owner needs help translating her traditional recipe collection. Perfect for Japanese speakers!',
|
||||||
|
location: 'Nishiki Market, Kyoto',
|
||||||
|
creator: nearbyTravelers[1],
|
||||||
|
timePosted: new Date()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const journeyCircles: JourneyCircle[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Sustainable Storytellers',
|
||||||
|
theme: 'Documenting Environmental Initiatives',
|
||||||
|
members: [nearbyTravelers[0]],
|
||||||
|
nextDestination: 'Rural Hokkaido',
|
||||||
|
impactGoal: 'Create a photo series of local sustainable farming practices'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Culture Bridges',
|
||||||
|
theme: 'Connecting Communities Through Art',
|
||||||
|
members: [nearbyTravelers[1]],
|
||||||
|
nextDestination: 'Osaka',
|
||||||
|
impactGoal: 'Organize a collaborative mural project with local artists'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full overflow-scroll ">
|
||||||
|
<main class="container mx-auto px-4 py-6">
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="tabs-boxed tabs my-4 justify-center bg-transparent">
|
||||||
|
{#each [{ id: 'magic', icon: '🔮', label: 'Magic' }, { id: 'allies', icon: '👥', label: 'Friends<3' }, { id: 'impact', icon: '🏆', label: 'Impact' }] as tab}
|
||||||
|
<button
|
||||||
|
class="tab-lg content-center tab p-10 gap-2 text-sm font-bold"
|
||||||
|
class:tab-active={activeTab === tab.id}
|
||||||
|
on:click={() => (activeTab = tab.id as any)}
|
||||||
|
>
|
||||||
|
<span class="text-lg">{tab.icon}</span>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === 'magic'}
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<!-- Serendipity Feed -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Today's Magic Opportunities ✨</h2>
|
||||||
|
{#each serendipityFeeds as feed}
|
||||||
|
<div class="card bg-base-100 shadow-sm transition-all hover:shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-12 rounded-full bg-primary text-primary-content">
|
||||||
|
{feed.type === 'hidden_gem' ? '💎' : '🛎️'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="mb-2 flex items-start justify-between">
|
||||||
|
<h3 class="card-title text-base">{feed.title}</h3>
|
||||||
|
<span class="text-xs opacity-50">
|
||||||
|
{feed.timePosted.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mb-4 text-sm opacity-75">{feed.description}</p>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="avatar">
|
||||||
|
<a href="/profile">
|
||||||
|
<div class="w-8 rounded-full">
|
||||||
|
<img src={feed.creator.avatar} alt={feed.creator.name} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<a href="/profile">
|
||||||
|
<span class="text-sm font-medium">{feed.creator.name}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm">
|
||||||
|
Join Now <span class="ml-1">➔</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Globe Section -->
|
||||||
|
<div class="card sticky top-4 bg-base-100 shadow-sm">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if activeTab === 'allies'}
|
||||||
|
<!-- Allies Section -->
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-xl font-bold">Potential Allies ({nearbyTravelers.length})</h2>
|
||||||
|
{#each nearbyTravelers as traveler}
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-12 rounded-xl" >
|
||||||
|
<img src={traveler.avatar} alt={traveler.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="mb-2 flex items-start justify-between">
|
||||||
|
<h3 class="card-title text-base">{traveler.name}</h3>
|
||||||
|
<div class="badge badge-primary gap-1">
|
||||||
|
⭐ {traveler.impactPoints}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-3 text-sm opacity-75">{traveler.location}</p>
|
||||||
|
<div class="mb-4 flex flex-wrap gap-2">
|
||||||
|
{#each traveler.interests as interest}
|
||||||
|
<div class="badge badge-outline">#{interest}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm btn-block"> Connect 🤝 </button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Journey Circles -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-bold">Featured Journey Circles</h2>
|
||||||
|
<button class="btn btn-link btn-sm">
|
||||||
|
See all <span class="ml-1">➔</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#each journeyCircles as circle}
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div
|
||||||
|
class="w-12 rounded-xl bg-gradient-to-br from-primary to-secondary text-primary-content"
|
||||||
|
>
|
||||||
|
<span>🌟</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="card-title text-base">{circle.name}</h3>
|
||||||
|
<p class="mb-3 text-sm opacity-75">{circle.theme}</p>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-primary">▶︎ Next:</span>
|
||||||
|
<span class="font-medium">{circle.nextDestination}</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-ghost">
|
||||||
|
{circle.members.length} members
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<progress class="progress progress-primary w-full" value="65" max="100"
|
||||||
|
></progress>
|
||||||
|
<span class="text-sm font-bold text-primary">65%</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm btn-block">
|
||||||
|
Join Circle <span class="ml-1">➔</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Impact Section -->
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-xl font-bold">Global Impact Dashboard</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="card bg-primary text-primary-content">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-2xl font-bold">12,847</div>
|
||||||
|
<div class="text-sm">Kind Acts</div>
|
||||||
|
<progress class="progress progress-info mt-2" value="82" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-2xl font-bold text-primary">47k+</div>
|
||||||
|
<div class="text-sm opacity-75">Lives Touched</div>
|
||||||
|
<div class="badge badge-success badge-sm mt-2 gap-1">▲ 12% from last month</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-2xl font-bold text-primary">284</div>
|
||||||
|
<div class="text-sm opacity-75">Projects</div>
|
||||||
|
<div class="mt-2 flex gap-1">
|
||||||
|
<div class="h-2 w-4 rounded-full bg-primary"></div>
|
||||||
|
<div class="h-2 w-4 rounded-full bg-primary/30"></div>
|
||||||
|
<div class="h-2 w-4 rounded-full bg-primary/30"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-2xl font-bold text-primary">89%</div>
|
||||||
|
<div class="text-sm opacity-75">Carbon Offset</div>
|
||||||
|
<div class="badge badge-ghost mt-2 gap-1">🌳 12k trees planted</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-xl font-bold">Your Impact Journey</h2>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-12 rounded-lg bg-primary text-primary-content">🏅</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="font-bold">Global Citizen</div>
|
||||||
|
<div class="text-sm opacity-75">Level 24 Explorer</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-sm opacity-75">Next Level</div>
|
||||||
|
<div class="text-lg font-bold text-primary">+1,240 XP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary mt-4" value="65" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
--rounded-box: 1rem;
|
||||||
|
--rounded-btn: 0.5rem;
|
||||||
|
--rounded-badge: 1.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
203
frontend/src/routes/(authed)/dm/+page.svelte
Normal file
203
frontend/src/routes/(authed)/dm/+page.svelte
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
lastMessage: string;
|
||||||
|
unread: number;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
senderId: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile = writable(false);
|
||||||
|
let selectedUserId: string | null = null;
|
||||||
|
$: showUserList = derived([isMobile], ([$isMobile]) => !$isMobile || !selectedUserId);
|
||||||
|
|
||||||
|
const users: User[] = [
|
||||||
|
{ id: '1', name: 'John Doe', avatar: 'https://i.pravatar.cc/40?img=1', lastMessage: 'Hey, how are you?', unread: 2, online: true },
|
||||||
|
{ id: '2', name: 'Jane Smith', avatar: 'https://i.pravatar.cc/40?img=2', lastMessage: 'See you tomorrow!', unread: 0, online: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function checkMobile() {
|
||||||
|
isMobile.set(window.innerWidth < 768);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(userId: string) {
|
||||||
|
selectedUserId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
selectedUserId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
let newMessage = '';
|
||||||
|
let messageContainer: HTMLDivElement;
|
||||||
|
|
||||||
|
let messages: Message[] = [];
|
||||||
|
$: messages = getMessages(selectedUserId);
|
||||||
|
|
||||||
|
function getMessages(userId: string | null): Message[] {
|
||||||
|
if (!userId) return [];
|
||||||
|
return userId === '1'
|
||||||
|
? [
|
||||||
|
{ id: '1', text: 'Hey, how are you?', senderId: '1', timestamp: '10:30 AM' },
|
||||||
|
{ id: '2', text: "I'm good, thanks!", senderId: 'me', timestamp: '10:31 AM' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: '3', text: 'Ready for tomorrow?', senderId: '2', timestamp: '2:45 PM' },
|
||||||
|
{ id: '4', text: 'Yes, see you then!', senderId: 'me', timestamp: '2:46 PM' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (newMessage.trim()) {
|
||||||
|
messages = [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
text: newMessage,
|
||||||
|
senderId: 'me',
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newMessage = '';
|
||||||
|
await tick();
|
||||||
|
messageContainer.scrollTo({ top: messageContainer.scrollHeight, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Smooth transition for mobile menu */
|
||||||
|
.menu-transition {
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increase button touch areas */
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle text overflow */
|
||||||
|
.truncate {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Main Layout -->
|
||||||
|
<div class="flex flex-col md:flex-row h-full ">
|
||||||
|
<!-- Mobile Header -->
|
||||||
|
<div class="md:hidden navbar bg-base-100 border-b border-base-300 px-4">
|
||||||
|
<div class="flex items-center w-full">
|
||||||
|
{#if selectedUserId}
|
||||||
|
<button on:click={goBack} class="btn btn-ghost btn-circle" aria-label="Back to conversations">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
|
<div class="w-10 rounded-full relative">
|
||||||
|
<img class=" rounded-lg" src={users.find(u => u.id === selectedUserId)?.avatar} alt={users.find(u => u.id === selectedUserId)?.name} />
|
||||||
|
{#if users.find(u => u.id === selectedUserId)?.online}
|
||||||
|
<div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-base-100 z-10"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<h1 class="max-h-fit text-lg font-semibold truncate flex-1 text-center max-w-fit">
|
||||||
|
{users.find(u => u.id === selectedUserId)?.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<h1 class="text-lg font-semibold flex-1 text-center">Messages</h1>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User List -->
|
||||||
|
<div class={`w-full md:w-80 bg-base-100 border-t border-r rounded-tr-lg border-base-300 fixed md:relative h-full menu-transition ${$showUserList ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||||
|
<div class="hidden md:block p-4 bg-neutral-50">
|
||||||
|
<h1 class="text-xl font-bold">Direct Messages</h1>
|
||||||
|
</div>
|
||||||
|
<div class="menu p-2 overflow-y-auto h-[calc(100vh-4rem)]">
|
||||||
|
{#each users as user (user.id)}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-3 p-3 hover:bg-base-200 rounded-lg {selectedUserId === user.id ? 'bg-base-200' : ''}"
|
||||||
|
on:click={() => selectUser(user.id)}
|
||||||
|
>
|
||||||
|
<div class="avatar online">
|
||||||
|
<div class="w-10 rounded-full relative">
|
||||||
|
<img src={user.avatar} alt={user.name} />
|
||||||
|
{#if user.online}
|
||||||
|
<div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-base-100 z-10"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 text-left min-w-0">
|
||||||
|
<div class="font-semibold truncate">{user.name}</div>
|
||||||
|
<div class="text-sm text-gray-500 truncate">{user.lastMessage}</div>
|
||||||
|
</div>
|
||||||
|
{#if user.unread > 0}
|
||||||
|
<div class="badge badge-primary">{user.unread}</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversation Area -->
|
||||||
|
<div class="flex-1 h-[calc(100vh-4rem)]">
|
||||||
|
{#if selectedUserId}
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="flex-1 p-4 space-y-4 overflow-y-auto" bind:this={messageContainer}>
|
||||||
|
{#each messages as message (message.id)}
|
||||||
|
<div class="flex {message.senderId === 'me' ? 'justify-end' : 'justify-start'}">
|
||||||
|
<div class="max-w-[90%] px-3 py-2 rounded-lg {message.senderId === 'me' ? 'bg-primary text-primary-content' : 'bg-base-300'}">
|
||||||
|
<div class="break-words">{message.text}</div>
|
||||||
|
<div class="text-[0.7rem] mt-1 opacity-50">{message.timestamp}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="p-4 border-t border-base-300 bg-base-100">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newMessage}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
class="flex-1 input input-bordered input-sm"
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && sendMessage()}
|
||||||
|
/>
|
||||||
|
<button on:click={sendMessage} class="btn btn-primary btn-circle">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="hidden md:flex items-center justify-center h-full">
|
||||||
|
<div class="text-center text-gray-500">
|
||||||
|
<div class="text-2xl">Select a conversation</div>
|
||||||
|
<div class="text-sm">Start chatting with your friends</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
112
frontend/src/routes/(authed)/home/+page.svelte
Normal file
112
frontend/src/routes/(authed)/home/+page.svelte
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<script>
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
// Fleets data
|
||||||
|
const fleets = [
|
||||||
|
{ user: 'Swipe', handle: '@user1', avatar: '😊' , url: '/home/swipe'},
|
||||||
|
{ user: 'Dinner', handle: '@user2', avatar: '😎', url: '/home/dinner'},
|
||||||
|
{ user: 'Chat', handle: '@user3', avatar: '🤩', url: '/home/chat'},
|
||||||
|
{ user: 'Plan', handle: '@user4', avatar: '🥳', url: '/home/plan'},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tweets data
|
||||||
|
const tweets = [
|
||||||
|
{
|
||||||
|
user: 'SvelteFan',
|
||||||
|
handle: '@svelte_dev',
|
||||||
|
avatar: '🚀',
|
||||||
|
content: "Just discovered Svelte and it's amazing! #webdev",
|
||||||
|
likes: 42,
|
||||||
|
retweets: 12,
|
||||||
|
time: '2h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: 'WebDev',
|
||||||
|
handle: '@webdev',
|
||||||
|
avatar: '💻',
|
||||||
|
content: 'CSS grid is a game changer for layouts. Change my mind.',
|
||||||
|
likes: 89,
|
||||||
|
retweets: 24,
|
||||||
|
time: '4h'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-4 max-w-2xl">
|
||||||
|
<!-- Fleets Section -->
|
||||||
|
<div class="flex space-x-4 pb-4 overflow-x-auto scrollbar-hide">
|
||||||
|
{#each fleets as fleet}
|
||||||
|
<div class="flex flex-col items-center space-y-2 flex-shrink-0">
|
||||||
|
<div class="p-1 rounded-full bg-gradient-to-tr from-primary to-secondary hover:from-secondary hover:to-primary">
|
||||||
|
<button on:click={goto(fleet.url)} class="avatar bg-base-200 rounded-full w-16 h-16 flex items-center justify-center text-2xl hover:opacity-90">
|
||||||
|
{fleet.avatar}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium">{fleet.user}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tweets Section -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each tweets as tweet}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-4">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="avatar bg-base-200 rounded-full w-12 h-12 flex items-center justify-center text-xl">
|
||||||
|
{tweet.avatar}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tweet Content -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-bold">{tweet.user}</span>
|
||||||
|
<span class="text-gray-600">{tweet.handle}</span>
|
||||||
|
<span class="text-gray-600">· {tweet.time}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2">{tweet.content}</p>
|
||||||
|
|
||||||
|
<!-- Tweet Actions -->
|
||||||
|
<div class="flex justify-between mt-4 text-gray-600 max-w-md">
|
||||||
|
<button class="hover:text-primary flex items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
{tweet.retweets}
|
||||||
|
</button>
|
||||||
|
<button class="hover:text-green-500 flex items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
{tweet.retweets}
|
||||||
|
</button>
|
||||||
|
<button class="hover:text-red-500 flex items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
{tweet.likes}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style global>
|
||||||
|
/* Custom scrollbar hide */
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
BIN
frontend/src/routes/(authed)/home/bg.png
Normal file
BIN
frontend/src/routes/(authed)/home/bg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 658 KiB |
BIN
frontend/src/routes/(authed)/home/bu.jpg
Normal file
BIN
frontend/src/routes/(authed)/home/bu.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 403 KiB |
78
frontend/src/routes/(authed)/home/chat/+page.svelte
Normal file
78
frontend/src/routes/(authed)/home/chat/+page.svelte
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let messages: any = [];
|
||||||
|
let newMessage = '';
|
||||||
|
let selectedLanguage = 'en';
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'es', label: 'Español' },
|
||||||
|
{ value: 'fr', label: 'Français' },
|
||||||
|
{ value: 'de', label: 'Deutsch' },
|
||||||
|
{ value: 'ja', label: '日本語' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function addMessage() {
|
||||||
|
if (!newMessage.trim()) return;
|
||||||
|
|
||||||
|
// Add user message
|
||||||
|
messages = [...messages, {
|
||||||
|
id: Date.now(),
|
||||||
|
content: newMessage,
|
||||||
|
isUser: true,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Simulate bot response
|
||||||
|
setTimeout(() => {
|
||||||
|
messages = [...messages, {
|
||||||
|
id: Date.now() + 1,
|
||||||
|
content: `Translated to ${selectedLanguage}: [${newMessage}]`,
|
||||||
|
isUser: false,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
}];
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
newMessage = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full overflow-scroll bg-base-200">
|
||||||
|
<!-- Language Selector -->
|
||||||
|
<div class="flex justify-end p-4 bg-base-100 shadow-sm">
|
||||||
|
<select
|
||||||
|
bind:value={selectedLanguage}
|
||||||
|
class="select select-primary select-sm w-full max-w-xs"
|
||||||
|
>
|
||||||
|
{#each languages as lang}
|
||||||
|
<option value={lang.value}>{lang.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Messages -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{#each messages as message}
|
||||||
|
<div class="chat {message.isUser ? 'chat-end' : 'chat-start'}">
|
||||||
|
<div class="chat-bubble {message.isUser ? 'chat-bubble-primary' : 'chat-bubble-secondary'}">
|
||||||
|
{message.content}
|
||||||
|
<div class="text-xs opacity-50 mt-1">{message.timestamp}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Input -->
|
||||||
|
<div class="p-4 bg-base-100 border-t">
|
||||||
|
<form on:submit|preventDefault={addMessage} class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newMessage}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
class="input input-bordered flex-1"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
BIN
frontend/src/routes/(authed)/home/clouds.png
Normal file
BIN
frontend/src/routes/(authed)/home/clouds.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 MiB |
113
frontend/src/routes/(authed)/home/dinner/+page.svelte
Normal file
113
frontend/src/routes/(authed)/home/dinner/+page.svelte
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<script>
|
||||||
|
let currentCity = "Lisbon"; // Dynamic location
|
||||||
|
const expeditions = [
|
||||||
|
{
|
||||||
|
name: "Tram 28 Tascas Crawl",
|
||||||
|
seats: 3,
|
||||||
|
cuisine: "Petiscos",
|
||||||
|
highlights: ["Sardines", "Green Wine", "Local Artists"],
|
||||||
|
meeting: "Praça Luís de Camões, 7pm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Alfama Family Feast",
|
||||||
|
seats: 5,
|
||||||
|
cuisine: "Seafood",
|
||||||
|
highlights: ["Cataplana Stew", "Fado Music", "Viewpoint"],
|
||||||
|
meeting: "Miradouro Santa Luzia, 7:30pm"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full overflow-scroll bg-base-100 font-[Inter]">
|
||||||
|
<!-- Header -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Local Context Bar -->
|
||||||
|
<div class="bg-primary/10 py-3 px-4 flex items-center gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm opacity-75">Currently Exploring</div>
|
||||||
|
<div class="text-xl font-bold text-primary">{currentCity}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-4xl">🍴</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expedition Cards -->
|
||||||
|
<main class="p-4 space-y-6">
|
||||||
|
{#each expeditions as expedition}
|
||||||
|
<div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Expedition Header -->
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-lg">{expedition.name}</h2>
|
||||||
|
<div class="badge badge-outline badge-sm mt-1">
|
||||||
|
{expedition.cuisine}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl">
|
||||||
|
{#if expedition.seats > 3}
|
||||||
|
🍽️
|
||||||
|
{:else}
|
||||||
|
🔥
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cultural Highlights -->
|
||||||
|
<div class="my-4 space-y-2">
|
||||||
|
{#each expedition.highlights as item}
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<div class="text-primary">✦</div>
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Status -->
|
||||||
|
<div class="flex items-center justify-between border-t pt-4">
|
||||||
|
<div class="flex -space-x-3">
|
||||||
|
{#each Array(expedition.seats) as _, i}
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-8 h-8 bg-neutral text-neutral-content">
|
||||||
|
{i === 0 ? '👤' : '?'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm">
|
||||||
|
Claim Seat ({expedition.seats} left)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meeting Details -->
|
||||||
|
<div class="mt-4 text-xs opacity-75 flex items-center gap-2">
|
||||||
|
<span class="flex-1">📍 {expedition.meeting}</span>
|
||||||
|
<span class="badge badge-ghost badge-sm">Local Guide: Maria</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Cultural Footprint -->
|
||||||
|
<div class="p-4 bg-base-200 mt-8">
|
||||||
|
<div class="max-w-md mx-auto text-center space-y-4">
|
||||||
|
<div class="text-3xl">🌐</div>
|
||||||
|
<h3 class="font-bold">Your Lisbon Journey</h3>
|
||||||
|
<div class="flex justify-center gap-4 text-sm">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-primary font-bold">5</div>
|
||||||
|
<div class="opacity-75">New Allies</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-primary font-bold">9</div>
|
||||||
|
<div class="opacity-75">Local Dishes</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-primary font-bold">3</div>
|
||||||
|
<div class="opacity-75">Neighborhoods</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
BIN
frontend/src/routes/(authed)/home/ea.jpg
Normal file
BIN
frontend/src/routes/(authed)/home/ea.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 MiB |
220
frontend/src/routes/(authed)/home/plan/+page.svelte
Normal file
220
frontend/src/routes/(authed)/home/plan/+page.svelte
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
let currentTab = 'pack';
|
||||||
|
let xp = 420;
|
||||||
|
let streak = 8;
|
||||||
|
let packedItems: any = [];
|
||||||
|
let newItem = '';
|
||||||
|
let destinations: any = [];
|
||||||
|
let newDestination = '';
|
||||||
|
let expenses: any = [];
|
||||||
|
let newExpense = '';
|
||||||
|
|
||||||
|
// Duolingo-style achievement system
|
||||||
|
const achievements = [
|
||||||
|
{ name: 'Packing Pro', earned: true },
|
||||||
|
{ name: 'Globetrotter', earned: false },
|
||||||
|
{ name: 'Budget Master', earned: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
if (newItem) {
|
||||||
|
packedItems = [...packedItems, {
|
||||||
|
name: newItem,
|
||||||
|
packed: false,
|
||||||
|
xp: 10,
|
||||||
|
category: ['🧳 Clothing', '📱 Tech', '🧼 Toiletry'][Math.floor(Math.random() * 3)]
|
||||||
|
}];
|
||||||
|
newItem = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePacked(item: any) {
|
||||||
|
packedItems = packedItems.map((i: { packed: any; }) =>
|
||||||
|
i === item ? {...i, packed: !i.packed} : i
|
||||||
|
);
|
||||||
|
if (!item.packed) xp += item.xp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDestination() {
|
||||||
|
destinations = [...destinations, {
|
||||||
|
name: newDestination,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
emoji: '🌍'
|
||||||
|
}];
|
||||||
|
newDestination = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExpense() {
|
||||||
|
expenses = [...expenses, {
|
||||||
|
description: newExpense,
|
||||||
|
amount: Math.floor(Math.random() * 100),
|
||||||
|
paidBy: 'You',
|
||||||
|
splitWith: ['Travel Buddy 1', 'Travel Buddy 2']
|
||||||
|
}];
|
||||||
|
newExpense = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@3.9.2/dist/full.css" rel="stylesheet">
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-base-200 p-8 font-[Poppins]">
|
||||||
|
<!-- XP & Streak Header -->
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<div class="stats shadow bg-primary text-primary-content">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Travel XP</div>
|
||||||
|
<div class="stat-value">{xp}</div>
|
||||||
|
<div class="stat-desc">Level {Math.floor(xp/100)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="indicator">
|
||||||
|
<span class="indicator-item badge badge-secondary">{streak}🔥</span>
|
||||||
|
<div class="grid w-32 h-20 bg-base-100 place-items-center rounded-box">
|
||||||
|
Streak!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="tabs tabs-boxed bg-accent mb-8">
|
||||||
|
<button
|
||||||
|
class:tab-active={currentTab === 'pack'}
|
||||||
|
on:click={() => currentTab = 'pack'}
|
||||||
|
class="tab text-lg"
|
||||||
|
>
|
||||||
|
🎒 Pack
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class:tab-active={currentTab === 'plan'}
|
||||||
|
on:click={() => currentTab = 'plan'}
|
||||||
|
class="tab text-lg"
|
||||||
|
>
|
||||||
|
🗺 Plan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class:tab-active={currentTab === 'split'}
|
||||||
|
on:click={() => currentTab = 'split'}
|
||||||
|
class="tab text-lg"
|
||||||
|
>
|
||||||
|
💸 Split
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Packing List Section -->
|
||||||
|
{#if currentTab === 'pack'}
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="join w-full">
|
||||||
|
<input
|
||||||
|
bind:value={newItem}
|
||||||
|
class="join-item input input-bordered w-full"
|
||||||
|
placeholder="Add item (e.g. passport)"
|
||||||
|
/>
|
||||||
|
<button on:click={addItem} class="join-item btn btn-primary">
|
||||||
|
<span class="text-2xl">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each packedItems as item (item.name)}
|
||||||
|
<div transition:fade class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.packed}
|
||||||
|
on:change={() => togglePacked(item)}
|
||||||
|
class="checkbox checkbox-primary checkbox-lg"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-xl font-bold">{item.name}</h3>
|
||||||
|
<div class="badge badge-outline">{item.category}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-success">+{item.xp}XP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if currentTab === 'plan'}
|
||||||
|
<!-- Trip Planning Section -->
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="join w-full">
|
||||||
|
<input
|
||||||
|
bind:value={newDestination}
|
||||||
|
class="join-item input input-bordered w-full"
|
||||||
|
placeholder="Add destination (e.g. Paris)"
|
||||||
|
/>
|
||||||
|
<button on:click={addDestination} class="join-item btn btn-secondary">
|
||||||
|
<span class="text-2xl">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="carousel rounded-box gap-4">
|
||||||
|
{#each destinations as destination (destination.name)}
|
||||||
|
<div class="carousel-item">
|
||||||
|
<div class="card bg-base-100 shadow-xl w-64">
|
||||||
|
<div class="card-body items-center text-center">
|
||||||
|
<div class="text-6xl mb-2">{destination.emoji}</div>
|
||||||
|
<h2 class="card-title">{destination.name}</h2>
|
||||||
|
<p>{destination.date}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Expense Splitting Section -->
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="join w-full">
|
||||||
|
<input
|
||||||
|
bind:value={newExpense}
|
||||||
|
class="join-item input input-bordered w-full"
|
||||||
|
placeholder="Add expense (e.g. Hotel)"
|
||||||
|
/>
|
||||||
|
<button on:click={addExpense} class="join-item btn btn-accent">
|
||||||
|
<span class="text-2xl">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each expenses as expense (expense.description)}
|
||||||
|
<div class="collapse collapse-arrow bg-base-100">
|
||||||
|
<input type="radio" name="expenses" />
|
||||||
|
<div class="collapse-title text-xl font-medium">
|
||||||
|
💰 {expense.description} - ${expense.amount}
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<p>Paid by: {expense.paidBy}</p>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
{#each expense.splitWith as person}
|
||||||
|
<div class="badge badge-lg badge-outline">
|
||||||
|
👤 {person}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Achievements Floating Button -->
|
||||||
|
<div class="fixed bottom-4 right-4">
|
||||||
|
<div class="dropdown dropdown-top dropdown-end">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-circle btn-primary text-2xl">
|
||||||
|
🏆
|
||||||
|
</div>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-64">
|
||||||
|
{#each achievements as achievement}
|
||||||
|
<li class={achievement.earned ? 'text-success' : 'text-base-content/50'}>
|
||||||
|
<a>★ {achievement.name}</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
308
frontend/src/routes/(authed)/home/swipe/+page.svelte
Normal file
308
frontend/src/routes/(authed)/home/swipe/+page.svelte
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { profiles } from '$lib/profiles';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
import { spring } from 'svelte/motion';
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
interests: string[];
|
||||||
|
moodImages?: string[];
|
||||||
|
location?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use proper typing for the stack
|
||||||
|
let currentIndex = 0;
|
||||||
|
let stack: Profile[] = profiles;
|
||||||
|
let connections: Profile[] = [];
|
||||||
|
let dragging: Profile | null = null;
|
||||||
|
let dragStartOffset = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
// Add proper typing to spring
|
||||||
|
let dragCoords = spring<{x: number, y: number}>({ x: 0, y: 0 }, {
|
||||||
|
stiffness: 0.2,
|
||||||
|
damping: 0.7
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add proper return type
|
||||||
|
const calculateConnectionPath = (connection: Profile): string => {
|
||||||
|
const start = connection.location || { x: 500, y: 250 };
|
||||||
|
// Store window dimensions to avoid repeated access
|
||||||
|
const windowWidth = window.innerWidth;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const end = { x: windowWidth - 100, y: windowHeight - 100 };
|
||||||
|
const controlPoint = {
|
||||||
|
x: (start.x + end.x) / 2,
|
||||||
|
y: Math.min(start.y, end.y) - 100
|
||||||
|
};
|
||||||
|
return `M ${start.x},${start.y} Q ${controlPoint.x},${controlPoint.y} ${end.x},${end.y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type the action parameter properly
|
||||||
|
const handleAction = (action: 'skip' | 'decide' | 'add'): void => {
|
||||||
|
if (stack.length === 0) return;
|
||||||
|
|
||||||
|
const profile = stack[currentIndex];
|
||||||
|
|
||||||
|
if (action === 'add') {
|
||||||
|
const draggedLocation = { x: $dragCoords.x, y: $dragCoords.y };
|
||||||
|
connections = [...connections, { ...profile, location: draggedLocation }];
|
||||||
|
|
||||||
|
const suitcase = document.querySelector('#suitcase');
|
||||||
|
const profileCard = document.querySelector(`#profile-${profile.id}`);
|
||||||
|
|
||||||
|
if (suitcase && profileCard) {
|
||||||
|
animateProfileToSuitcase(profileCard, suitcase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the current profile and update the stack
|
||||||
|
stack = stack.filter((_, i) => i !== currentIndex);
|
||||||
|
currentIndex = Math.min(currentIndex, stack.length - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract animation logic to a separate function
|
||||||
|
const animateProfileToSuitcase = (profileCard: Element, suitcase: Element): void => {
|
||||||
|
const clone = profileCard.cloneNode(true) as HTMLElement;
|
||||||
|
document.body.appendChild(clone);
|
||||||
|
|
||||||
|
const start = profileCard.getBoundingClientRect();
|
||||||
|
const end = suitcase.getBoundingClientRect();
|
||||||
|
|
||||||
|
clone.style.position = 'fixed';
|
||||||
|
clone.style.left = `${start.left}px`;
|
||||||
|
clone.style.top = `${start.top}px`;
|
||||||
|
clone.style.width = `${start.width}px`;
|
||||||
|
clone.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
clone.style.zIndex = '9999';
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
clone.style.transform = `translate(${end.left - start.left}px, ${end.top - start.top}px) scale(0.1) rotate(360deg)`;
|
||||||
|
clone.style.opacity = '0';
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => clone.remove(), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag = (event: MouseEvent, profile: Profile): void => {
|
||||||
|
const element = event.currentTarget as HTMLElement;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
dragStartOffset = {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top
|
||||||
|
};
|
||||||
|
|
||||||
|
dragging = profile;
|
||||||
|
dragCoords.set({
|
||||||
|
x: event.clientX - dragStartOffset.x,
|
||||||
|
y: event.clientY - dragStartOffset.y
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.style.cursor = 'grabbing';
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleMouseMove(event: MouseEvent): void {
|
||||||
|
if (dragging) {
|
||||||
|
dragCoords.set({
|
||||||
|
x: event.clientX - dragStartOffset.x,
|
||||||
|
y: event.clientY - dragStartOffset.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp(): void {
|
||||||
|
if (dragging) {
|
||||||
|
const suitcase = document.querySelector('#suitcase');
|
||||||
|
if (suitcase) {
|
||||||
|
const suitcaseRect = suitcase.getBoundingClientRect();
|
||||||
|
const dragRect = document.querySelector(`#profile-${dragging.id}`)?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (dragRect && isOverlapping(dragRect, suitcaseRect)) {
|
||||||
|
handleAction('add');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragging = null;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverlapping(rect1: DOMRect, rect2: DOMRect): boolean {
|
||||||
|
return !(rect1.right < rect2.left ||
|
||||||
|
rect1.left > rect2.right ||
|
||||||
|
rect1.bottom < rect2.top ||
|
||||||
|
rect1.top > rect2.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent, profile: Profile): void {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
const element = event.currentTarget as HTMLElement;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
startDrag(new MouseEvent('mousedown', {
|
||||||
|
clientX: rect.left + rect.width / 2,
|
||||||
|
clientY: rect.top + rect.height / 2
|
||||||
|
}), profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cleanup for window event listeners
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:mousemove={handleMouseMove} on:mouseup={handleMouseUp} />
|
||||||
|
|
||||||
|
<div class="h-full font-handwritten relative overflow-hidden">
|
||||||
|
<!-- Interactive World Map Background -->
|
||||||
|
<div class="absolute inset-0 opacity-25 z-0 pointer-events-none">
|
||||||
|
<svg viewBox="0 0 1000 500" class="w-full h-full" role="presentation">
|
||||||
|
{#each connections as connection (connection.id)}
|
||||||
|
<path d={calculateConnectionPath(connection)}
|
||||||
|
class="stroke-pink-400/40 stroke-2 animate-dash"
|
||||||
|
fill="none" />
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suitcase Collector -->
|
||||||
|
<div id="suitcase"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Collected connections: {connections.length}"
|
||||||
|
class="fixed bottom-8 right-8 w-24 h-16 bg-amber-500/80 rotate-12 shadow-xl rounded-lg z-30
|
||||||
|
transition-all hover:rotate-6 hover:scale-110 hover:shadow-2xl
|
||||||
|
{dragging ? 'scale-125 ring-4 ring-green-400 ring-opacity-50' : ''}"
|
||||||
|
style="transform-origin: center;">
|
||||||
|
<span class="absolute -top-2 -left-2 text-4xl" aria-hidden="true">🧳</span>
|
||||||
|
<span class="absolute bottom-1 right-1 text-sm font-bold text-white" aria-hidden="true">
|
||||||
|
{connections.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Stack -->
|
||||||
|
<div class="relative h-screen flex items-center justify-center">
|
||||||
|
{#each stack as profile, i (profile.id)}
|
||||||
|
<div id={`profile-${profile.id}`}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Profile card for {profile.name}"
|
||||||
|
class="absolute cursor-grab active:cursor-grabbing transition-transform duration-200
|
||||||
|
{dragging === profile ? 'z-50' : ''}"
|
||||||
|
style={`transform: ${dragging !== profile ?
|
||||||
|
`rotate(${Math.random() * 8 - 4}deg)
|
||||||
|
translate(${Math.random() * 20 - 10}px, ${Math.random() * 20 - 10}px)` :
|
||||||
|
'none'};
|
||||||
|
z-index: ${1000 - i};
|
||||||
|
${dragging === profile ?
|
||||||
|
`position: fixed; left: ${$dragCoords.x}px; top: ${$dragCoords.y}px; transform: none;` :
|
||||||
|
''}`}
|
||||||
|
animate:flip={{ delay: i * 50, duration: 800, easing: quintOut }}
|
||||||
|
on:mousedown={(e) => startDrag(e, profile)}
|
||||||
|
on:keydown={(e) => handleKeyDown(e, profile)}>
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-xl border-4 border-double border-amber-700
|
||||||
|
transition-all hover:shadow-2xl hover:scale-105 origin-center
|
||||||
|
backdrop-blur-sm bg-opacity-90"
|
||||||
|
style="width: min(80vw, 400px);">
|
||||||
|
<h3 class="text-3xl mb-4 text-cyan-800">{profile.name}</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
{#each profile.interests as interest (interest)}
|
||||||
|
<span class="px-3 py-1 bg-yellow-200/50 rounded-full
|
||||||
|
border-2 border-dashed border-amber-600/30">
|
||||||
|
{interest}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="h-48 bg-stone-50 rounded border-2 border-stone-200 mb-4 relative overflow-hidden">
|
||||||
|
{#if profile.moodImages}
|
||||||
|
{#each profile.moodImages as img, index (img)}
|
||||||
|
<img src={img}
|
||||||
|
alt={`Mood image ${index + 1} for ${profile.name}`}
|
||||||
|
class="absolute w-16 h-16 object-cover shadow-lg border-4 border-white"
|
||||||
|
style={`left: ${Math.random() * 60}%;
|
||||||
|
top: ${Math.random() * 60}%;
|
||||||
|
transform: rotate(${Math.random() * 24 - 12}deg);`} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="fixed bottom-8 left-8 flex gap-4 z-30">
|
||||||
|
<button on:click={() => handleAction('skip')}
|
||||||
|
aria-label="Skip profile"
|
||||||
|
class="h-24 w-24 bg-red-500/90 rotate-12 shadow-xl rounded-lg
|
||||||
|
flex items-center justify-center text-white text-4xl
|
||||||
|
hover:rotate-0 hover:scale-125 transition-all active:scale-95
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={stack.length === 0}>
|
||||||
|
<span class="rotate--12" aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button on:click={() => handleAction('decide')}
|
||||||
|
aria-label="Decide later"
|
||||||
|
class="h-24 w-24 bg-purple-500/90 -rotate-6 shadow-xl rounded-lg
|
||||||
|
flex items-center justify-center text-white text-4xl
|
||||||
|
hover:rotate-3 hover:scale-125 transition-all active:scale-95
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={stack.length === 0}>
|
||||||
|
<span class="rotate-6" aria-hidden="true">⌛</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button on:click={() => handleAction('add')}
|
||||||
|
aria-label="Add to connections"
|
||||||
|
class="h-24 w-24 bg-green-500/90 rotate-3 shadow-xl rounded-lg
|
||||||
|
flex items-center justify-center text-white text-4xl
|
||||||
|
hover:-rotate-12 hover:scale-125 transition-all active:scale-95
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={stack.length === 0}>
|
||||||
|
<span class="rotate--3" aria-hidden="true">➕</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dragging Preview -->
|
||||||
|
{#if dragging}
|
||||||
|
<div class="fixed pointer-events-none z-50"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
style={`left: ${$dragCoords.x}px; top: ${$dragCoords.y}px;
|
||||||
|
transform: translate(-50%, -50%) rotate(${Math.random() * 25 - 12.5}deg);`}>
|
||||||
|
<div class="bg-white p-4 rounded shadow-xl border-2 border-dashed border-amber-600">
|
||||||
|
Taking {dragging.name} on adventure!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes dash {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-dash {
|
||||||
|
stroke-dasharray: 10;
|
||||||
|
animation: dash 10s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-grab {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-grabbing {
|
||||||
|
cursor: grabbing;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
76
frontend/src/routes/(authed)/notifications/+page.svelte
Normal file
76
frontend/src/routes/(authed)/notifications/+page.svelte
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<script>
|
||||||
|
import { BellIcon, CheckIcon, GlobeIcon, UserIcon } from 'lucide-svelte';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
|
let notifications = [
|
||||||
|
{ type: 'alert', message: 'Lena accepted your Paris trip invite!', timestamp: '2m ago', read: false },
|
||||||
|
{ type: 'update', message: 'New local experience in Kyoto: Tea ceremony with master Takano', timestamp: '1h ago', read: false },
|
||||||
|
{ type: 'friend', message: 'Marc from your Barcelona hostel wants to connect', timestamp: '4h ago', read: true },
|
||||||
|
{ type: 'success', message: 'Your Bali itinerary is ready! 3 locals joined the plan', timestamp: '1d ago', read: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
function markAllRead() {
|
||||||
|
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen ">
|
||||||
|
|
||||||
|
|
||||||
|
<main class="max-w-2xl mx-auto p-4 space-y-6">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||||
|
<GlobeIcon class="w-8 h-8 text-accent" />
|
||||||
|
Journey Updates
|
||||||
|
</h1>
|
||||||
|
<button on:click={markAllRead} class="btn btn-sm btn-outline">Mark all as read</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if notifications.length === 0}
|
||||||
|
<div class="text-center p-8 rounded-xl bg-base-100">
|
||||||
|
<p class="text-lg text-gray-500">No new updates - your next adventure is waiting!</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each notifications as notification (notification.timestamp)}
|
||||||
|
<div
|
||||||
|
transition:fly={{ y: 20, duration: 300 }}
|
||||||
|
class:opacity-50={notification.read}
|
||||||
|
class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
{#if notification.type === 'alert'}
|
||||||
|
<div class="text-warning">
|
||||||
|
<UserIcon class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
{:else if notification.type === 'update'}
|
||||||
|
<div class="text-info">
|
||||||
|
<GlobeIcon class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
{:else if notification.type === 'friend'}
|
||||||
|
<div class="text-secondary">
|
||||||
|
<UserIcon class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-success">
|
||||||
|
<CheckIcon class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium">{notification.message}</p>
|
||||||
|
<p class="text-sm text-gray-500">{notification.timestamp}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !notification.read}
|
||||||
|
<div class="w-2 h-2 bg-primary rounded-full self-center ml-4 animate-pulse" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
562
frontend/src/routes/(authed)/profile/+page.svelte
Normal file
562
frontend/src/routes/(authed)/profile/+page.svelte
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { animate } from 'motion';
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
Users,
|
||||||
|
Plane,
|
||||||
|
Calendar,
|
||||||
|
Heart,
|
||||||
|
MessageCircle,
|
||||||
|
Instagram,
|
||||||
|
Twitter,
|
||||||
|
Youtube,
|
||||||
|
Flame,
|
||||||
|
Crown,
|
||||||
|
Languages,
|
||||||
|
Trophy,
|
||||||
|
|
||||||
|
Clock
|
||||||
|
,
|
||||||
|
|
||||||
|
ListMusic
|
||||||
|
,
|
||||||
|
|
||||||
|
Music
|
||||||
|
,
|
||||||
|
|
||||||
|
Play
|
||||||
|
,
|
||||||
|
|
||||||
|
RepeatIcon
|
||||||
|
,
|
||||||
|
|
||||||
|
Shuffle
|
||||||
|
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
let floating = false;
|
||||||
|
let isFollowing = false;
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
name: 'Adventure Alex',
|
||||||
|
bio: '🌍 Global Wanderer | Making friends one journey at a time',
|
||||||
|
location: 'Currently in Bali, Indonesia',
|
||||||
|
stats: [
|
||||||
|
{ label: 'Countries', value: 27, icon: Globe },
|
||||||
|
{ label: 'Friends', value: 143, icon: Users },
|
||||||
|
{ label: 'Trips', value: 89, icon: Plane }
|
||||||
|
],
|
||||||
|
upcomingTrips: [
|
||||||
|
{ id: 1, destination: 'Tokyo, Japan', date: 'March 15, 2024' },
|
||||||
|
{ id: 2, destination: 'Patagonia, Chile', date: 'April 20, 2024' }
|
||||||
|
],
|
||||||
|
socials: [
|
||||||
|
{ name: 'Instagram', handle: '@adventure_alex', url: '#', icon: Instagram },
|
||||||
|
{ name: 'Twitter', handle: '@wanderlust_alex', url: '#', icon: Twitter },
|
||||||
|
{ name: 'YouTube', handle: 'Adventure Alex', url: '#', icon: Youtube }
|
||||||
|
],
|
||||||
|
posts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Sunset in Uluwatu 🌅',
|
||||||
|
content: 'Chasing golden hours with amazing new friends! #BaliVibes',
|
||||||
|
likes: 142,
|
||||||
|
comments: 28,
|
||||||
|
date: '2024-02-20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Mountain Trekking 🏔️',
|
||||||
|
content: 'Conquered Mount Batur at sunrise! Worth every step.',
|
||||||
|
likes: 89,
|
||||||
|
comments: 15,
|
||||||
|
date: '2024-02-18'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
duolingo: {
|
||||||
|
streakDays: 145,
|
||||||
|
totalXP: 24680,
|
||||||
|
crown: 78,
|
||||||
|
languages: [
|
||||||
|
{
|
||||||
|
name: 'Spanish',
|
||||||
|
level: 'Advanced',
|
||||||
|
progress: 85,
|
||||||
|
xp: 12400,
|
||||||
|
streakWeeks: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Japanese',
|
||||||
|
level: 'Intermediate',
|
||||||
|
progress: 65,
|
||||||
|
xp: 8200,
|
||||||
|
streakWeeks: 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'French',
|
||||||
|
level: 'Beginner',
|
||||||
|
progress: 25,
|
||||||
|
xp: 4080,
|
||||||
|
streakWeeks: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
spotify: {
|
||||||
|
topArtists: [
|
||||||
|
{ name: 'The Lumineers', genre: 'Folk Rock', imageUrl: '/api/placeholder/60/60' },
|
||||||
|
{ name: 'Coldplay', genre: 'Alternative Rock', imageUrl: '/api/placeholder/60/60' },
|
||||||
|
{ name: 'Ed Sheeran', genre: 'Pop', imageUrl: '/api/placeholder/60/60' }
|
||||||
|
],
|
||||||
|
recentlyPlayed: [
|
||||||
|
{
|
||||||
|
title: 'Ho Hey',
|
||||||
|
artist: 'The Lumineers',
|
||||||
|
album: 'The Lumineers',
|
||||||
|
duration: '2:43',
|
||||||
|
imageUrl: '/api/placeholder/50/50'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Viva La Vida',
|
||||||
|
artist: 'Coldplay',
|
||||||
|
album: 'Viva la Vida',
|
||||||
|
duration: '3:45',
|
||||||
|
imageUrl: '/api/placeholder/50/50'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Shape of You',
|
||||||
|
artist: 'Ed Sheeran',
|
||||||
|
album: '÷ (Divide)',
|
||||||
|
duration: '3:53',
|
||||||
|
imageUrl: '/api/placeholder/50/50'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
playlists: [
|
||||||
|
{
|
||||||
|
name: 'Travel Vibes',
|
||||||
|
tracks: 45,
|
||||||
|
imageUrl: '/api/placeholder/80/80'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Workout Mix',
|
||||||
|
tracks: 32,
|
||||||
|
imageUrl: '/api/placeholder/80/80'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Chill Evening',
|
||||||
|
tracks: 28,
|
||||||
|
imageUrl: '/api/placeholder/80/80'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
currentlyPlaying: {
|
||||||
|
title: 'Ho Hey',
|
||||||
|
artist: 'The Lumineers',
|
||||||
|
album: 'The Lumineers',
|
||||||
|
progress: 65,
|
||||||
|
imageUrl: '/api/placeholder/60/60'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFollow = () => {
|
||||||
|
isFollowing = !isFollowing;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const header = document.querySelector('.profile-header') as any;
|
||||||
|
const stats = document.querySelectorAll('.stat-card') as any;
|
||||||
|
const posts = document.querySelectorAll('.post-card') as any;
|
||||||
|
const trips = document.querySelectorAll('.trip-card') as any;
|
||||||
|
const plane = document.querySelector('.floating-plane') as any;
|
||||||
|
|
||||||
|
if (header) {
|
||||||
|
animate(header, { opacity: [0, 1], y: [-20, 0] }, {
|
||||||
|
duration: 0.8,
|
||||||
|
easing: 'easeOut'
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.forEach((stat: any, index: any) => {
|
||||||
|
animate(stat, { opacity: [0, 1], scale: [0.8, 1] }, {
|
||||||
|
delay: index * 0.1,
|
||||||
|
duration: 0.5,
|
||||||
|
easing: 'easeOut'
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
posts.forEach((post: any, index: any) => {
|
||||||
|
animate(post, { opacity: [0, 1], y: [20, 0] }, {
|
||||||
|
delay: 0.3 + index * 0.1,
|
||||||
|
duration: 0.6,
|
||||||
|
easing: 'easeOut'
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
trips.forEach((trip: any, index: any) => {
|
||||||
|
animate(trip, { opacity: [0, 1], x: [-20, 0] }, {
|
||||||
|
delay: 0.4 + index * 0.1,
|
||||||
|
duration: 0.7,
|
||||||
|
easing: 'easeOut'
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (plane) {
|
||||||
|
setInterval(() => {
|
||||||
|
animate(plane, { y: floating ? -10 : 0 }, { duration: 1.5, easing: 'easeInOut' } as any);
|
||||||
|
floating = !floating;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageCards = document.querySelectorAll('.language-card') as any;
|
||||||
|
|
||||||
|
languageCards.forEach((card: any, index: any) => {
|
||||||
|
animate(card, { opacity: [0, 1], x: [20, 0] }, {
|
||||||
|
delay: 0.5 + index * 0.1,
|
||||||
|
duration: 0.6,
|
||||||
|
easing: 'easeOut'
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
const spotifyCards = document.querySelectorAll('.spotify-card') as any;
|
||||||
|
const trackItems = document.querySelectorAll('.track-item') as any;
|
||||||
|
|
||||||
|
spotifyCards.forEach((card: any, index: any) => {
|
||||||
|
animate(card, { opacity: [0, 1], y: [20, 0] }, {
|
||||||
|
delay: 0.3 + index * 0.1,
|
||||||
|
duration: 0.6,
|
||||||
|
easing: 'easeOut'
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
trackItems.forEach((item: any, index: any) => {
|
||||||
|
animate(item, { opacity: [0, 1], x: [-20, 0] }, {
|
||||||
|
delay: 0.4 + index * 0.1,
|
||||||
|
duration: 0.5,
|
||||||
|
easing: 'easeOut'
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let isPlaying = false;
|
||||||
|
const togglePlay = () => {
|
||||||
|
isPlaying = !isPlaying;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full overflow-scroll py-8 mb-14">
|
||||||
|
<!-- Floating Airplane -->
|
||||||
|
<div class="floating-plane absolute right-10 top-20 text-blue-500">
|
||||||
|
<Plane size={48} class="-rotate-45 transform" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-4xl overflow-hidden rounded-xl bg-white shadow-xl">
|
||||||
|
<!-- Profile Header -->
|
||||||
|
<section
|
||||||
|
class="profile-header relative bg-gradient-to-b from-blue-100 to-white p-8 text-center"
|
||||||
|
>
|
||||||
|
<div class="mx-auto h-32 w-32 overflow-hidden rounded-full border-4 border-blue-500">
|
||||||
|
<img
|
||||||
|
src="https://w7.pngwing.com/pngs/48/259/png-transparent-profile-man-male-photo-face-portrait-illustration-vector-people-blue.png"
|
||||||
|
alt="Adventure Alex"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-4 text-2xl font-bold text-gray-900">{user.name}</h1>
|
||||||
|
<p class="max-h-fit text-sm text-gray-500">{user.socials[0].handle}</p>
|
||||||
|
<p class="text-sm text-gray-500">{user.location}</p>
|
||||||
|
<p class="text-gray-600">{user.bio}</p>
|
||||||
|
|
||||||
|
<!-- Follow Button -->
|
||||||
|
<button
|
||||||
|
on:click={toggleFollow}
|
||||||
|
class="btn btn-primary mt-4 transform rounded-full px-6 py-2 text-sm font-medium transition-all hover:scale-105 focus:outline-none
|
||||||
|
{isFollowing ? 'btn-secondary' : ''}"
|
||||||
|
>
|
||||||
|
{isFollowing ? 'Following' : 'Follow'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Social Media Links -->
|
||||||
|
<div in:fade={{ duration: 400 }} class="flex justify-center gap-6 bg-blue-50 py-4">
|
||||||
|
{#each user.socials as social}
|
||||||
|
<a
|
||||||
|
href={social.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-2 text-gray-600 transition-colors hover:text-blue-500"
|
||||||
|
aria-label={`Visit ${social.name} profile`}
|
||||||
|
>
|
||||||
|
<svelte:component this={social.icon} size={20} />
|
||||||
|
<span class="text-sm font-medium">{social.handle}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<section class="grid grid-cols-3 gap-4 p-8">
|
||||||
|
{#each user.stats as stat}
|
||||||
|
<div class="stat-card rounded-lg bg-white p-6 text-center shadow-md">
|
||||||
|
<svelte:component this={stat.icon} class="mb-2 text-blue-500" size={24} />
|
||||||
|
<h3 class="text-3xl font-bold text-gray-900">{stat.value}</h3>
|
||||||
|
<p class="text-sm text-gray-600">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Posts -->
|
||||||
|
<section class="p-6">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold text-gray-900">Recent Adventures</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each user.posts as post}
|
||||||
|
<div
|
||||||
|
class="post-card rounded-xl border border-gray-200 bg-white p-4 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<!-- User Avatar -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src="https://w7.pngwing.com/pngs/48/259/png-transparent-profile-man-male-photo-face-portrait-illustration-vector-people-blue.png"
|
||||||
|
alt="Adventure Alex"
|
||||||
|
class="h-12 w-12 rounded-full border-2 border-blue-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tweet Content -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="font-bold text-gray-900">{user.name}</span>
|
||||||
|
<span class="text-gray-500">
|
||||||
|
{user.socials.find((s) => s.name === 'Twitter')?.handle}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400">·</span>
|
||||||
|
<time class="text-gray-500" datetime={post.date}>
|
||||||
|
{formatDate(post.date)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tweet Body -->
|
||||||
|
<p class="mt-2 leading-relaxed text-gray-900">
|
||||||
|
{post.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Tweet Actions -->
|
||||||
|
<div class="mt-3 flex gap-6 text-gray-500">
|
||||||
|
<button class="flex items-center gap-2 transition-colors hover:text-blue-500">
|
||||||
|
<MessageCircle size={18} />
|
||||||
|
<span class="text-sm">{post.comments}</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex items-center gap-2 transition-colors hover:text-red-500">
|
||||||
|
<Heart size={18} />
|
||||||
|
<span class="text-sm">{post.likes}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Duolingo Section -->
|
||||||
|
<section class="bg-green-50 p-8">
|
||||||
|
<div class="mb-6 flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src="https://cdn.worldvectorlogo.com/logos/duolingo-symbol-icon.svg"
|
||||||
|
alt="Duolingo"
|
||||||
|
class="mx-4 h-12"
|
||||||
|
/>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900">Language Learning Journey</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="mb-8 grid grid-cols-3 gap-4">
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-md transition-all hover:shadow-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Flame class="text-orange-500" size={24} />
|
||||||
|
<div>
|
||||||
|
<h3 class="text-3xl font-bold text-gray-900">{user.duolingo.streakDays}</h3>
|
||||||
|
<p class="text-sm text-gray-600">Day Streak</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-md transition-all hover:shadow-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Trophy class="text-yellow-500" size={24} />
|
||||||
|
<div>
|
||||||
|
<h3 class="text-3xl font-bold text-gray-900">{user.duolingo.totalXP}</h3>
|
||||||
|
<p class="text-sm text-gray-600">Total XP</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-md transition-all hover:shadow-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Crown class="text-purple-500" size={24} />
|
||||||
|
<div>
|
||||||
|
<h3 class="text-3xl font-bold text-gray-900">{user.duolingo.crown}</h3>
|
||||||
|
<p class="text-sm text-gray-600">Crown Level</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Language Progress Cards -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each user.duolingo.languages as language}
|
||||||
|
<div
|
||||||
|
class="language-card rounded-xl bg-white p-6 shadow-md transition-all hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Languages class="text-green-500" size={24} />
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">{language.name}</h3>
|
||||||
|
<p class="text-sm text-gray-600">{language.level}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-lg font-bold text-green-500">{language.xp} XP</p>
|
||||||
|
<p class="text-sm text-gray-600">{language.streakWeeks} week streak</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
class="h-full bg-green-500 transition-all duration-500"
|
||||||
|
style="width: {language.progress}%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Spotify Section -->
|
||||||
|
<section class="bg-gradient-to-b from-green-900 to-black p-8 text-white">
|
||||||
|
<div class="mb-6 flex items-center gap-3">
|
||||||
|
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-2xl font-bold">Music Profile</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<!-- Top Artists -->
|
||||||
|
<div class="spotify-card rounded-xl bg-white bg-opacity-20 p-6">
|
||||||
|
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Music size={20} />
|
||||||
|
Top Artists
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each user.spotify.topArtists as artist}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img src={artist.imageUrl} alt={artist.name} class="h-12 w-12 rounded-full" />
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold">{artist.name}</h4>
|
||||||
|
<p class="text-sm text-gray-300">{artist.genre}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recently Played -->
|
||||||
|
<div class="spotify-card rounded-xl bg-white bg-opacity-20 p-6">
|
||||||
|
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Clock size={20} />
|
||||||
|
Current Obsession
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each user.spotify.recentlyPlayed as track}
|
||||||
|
<div class="track-item flex items-center gap-4">
|
||||||
|
<img src={track.imageUrl} alt={track.title} class="h-10 w-10 rounded" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold">{track.title}</h4>
|
||||||
|
<p class="text-sm text-gray-300">{track.artist}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-300">{track.duration}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlists -->
|
||||||
|
<div class="my-7">
|
||||||
|
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<ListMusic size={20} />
|
||||||
|
Public Playlists
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
|
{#each user.spotify.playlists as playlist}
|
||||||
|
<div
|
||||||
|
class="spotify-card rounded-xl bg-white bg-opacity-20 p-4 transition-all hover:bg-opacity-30"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={playlist.imageUrl}
|
||||||
|
alt={playlist.name}
|
||||||
|
class="mb-5 h-full w-full rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
<h4 class="font-semibold">{playlist.name}</h4>
|
||||||
|
<p class="text-sm text-gray-300">{playlist.tracks} tracks</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Upcoming Trips -->
|
||||||
|
<section class="p-8">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold text-gray-900">Upcoming Trips</h2>
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#each user.upcomingTrips as trip}
|
||||||
|
<div
|
||||||
|
class="trip-card flex items-center justify-between rounded-lg bg-white p-6 shadow-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">{trip.destination}</h3>
|
||||||
|
<div class="flex items-center gap-2 text-gray-600">
|
||||||
|
<Calendar size={16} /> <time datetime={trip.date}>{trip.date}</time>
|
||||||
|
-
|
||||||
|
<Calendar size={16} /> <time datetime={trip.date}>{trip.date}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary rounded-full px-6 py-2 text-sm font-medium">
|
||||||
|
Join Trip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Custom hover effect */
|
||||||
|
.hover-scale {
|
||||||
|
transition: transform 150ms ease-out;
|
||||||
|
}
|
||||||
|
.hover-scale:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-card {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
157
frontend/src/routes/(authed)/search/+page.svelte
Normal file
157
frontend/src/routes/(authed)/search/+page.svelte
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<!-- Search.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let searchQuery = '';
|
||||||
|
let showError = false;
|
||||||
|
|
||||||
|
type FilterKeys = 'accommodation' | 'activities' | 'localGuides' | 'safetyTips' | 'meetups';
|
||||||
|
type Filters = Record<FilterKeys, boolean>;
|
||||||
|
const filterKeys: FilterKeys[] = ['accommodation', 'activities', 'localGuides', 'safetyTips', 'meetups'];
|
||||||
|
|
||||||
|
let selectedFilters: Filters = {
|
||||||
|
accommodation: false,
|
||||||
|
activities: false,
|
||||||
|
localGuides: false,
|
||||||
|
safetyTips: false,
|
||||||
|
meetups: false
|
||||||
|
};
|
||||||
|
|
||||||
|
let recentSearches: string[] = [];
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const savedSearches = localStorage.getItem('recentSearches');
|
||||||
|
recentSearches = savedSearches ? JSON.parse(savedSearches) : [
|
||||||
|
'Local guides in Barcelona',
|
||||||
|
'Safe hostels Tokyo',
|
||||||
|
'Community meetups Paris'
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
if (!searchQuery.trim() && !Object.values(selectedFilters).some(v => v)) {
|
||||||
|
showError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError = false;
|
||||||
|
|
||||||
|
// Save search query to recent searches
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const newSearch = searchQuery.trim();
|
||||||
|
if (!recentSearches.includes(newSearch)) {
|
||||||
|
recentSearches = [newSearch, ...recentSearches].slice(0, 3);
|
||||||
|
localStorage.setItem('recentSearches', JSON.stringify(recentSearches));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Searching:', searchQuery, selectedFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchQuery = '';
|
||||||
|
showError = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full p-4 md:p-8">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="hero bg-base-100 rounded-box mb-8 p-4 md:p-6" in:fly={{ y: 50, duration: 500 }}>
|
||||||
|
<div class="hero-content text-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl md:text-4xl font-bold mb-4">Discover Your Community</h1>
|
||||||
|
<p class="text-base md:text-lg opacity-80 max-w-md mx-auto">
|
||||||
|
Connect with fellow travelers, find safe spaces, and create serendipitous moments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="form-control mx-10 mb-4 ">
|
||||||
|
<div class="input-group flex gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder="Search for local guides, safe spaces, or community meetups..."
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
aria-label="Search input"
|
||||||
|
/>
|
||||||
|
{#if searchQuery}
|
||||||
|
<button class="btn btn-square" on:click={clearSearch} aria-label="Clear search">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn btn-primary" on:click={handleSearch} aria-label="Search">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if showError}
|
||||||
|
<div class="text-error text-sm mt-2 text-center">
|
||||||
|
Please enter a search term or select at least one filter
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Tags -->
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center mb-6">
|
||||||
|
{#each filterKeys as filter}
|
||||||
|
<label class="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="hidden"
|
||||||
|
bind:checked={selectedFilters[filter]}
|
||||||
|
aria-label={`Filter by ${filter}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="badge badge-lg {selectedFilters[filter] ? 'badge-primary' : 'badge-outline'} p-4"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selectedFilters[filter]}
|
||||||
|
>
|
||||||
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Searches -->
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-3xl mx-auto">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">Recent Community Searches</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each recentSearches as search (search)}
|
||||||
|
<div
|
||||||
|
animate:flip={{ duration: 300 }}
|
||||||
|
class="flex items-center justify-between p-3 hover:bg-base-200 active:bg-base-300 rounded-lg cursor-pointer transition-colors"
|
||||||
|
on:click={() => {
|
||||||
|
searchQuery = search;
|
||||||
|
handleSearch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{search}</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" aria-label="Apply search">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.badge) {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.badge:hover) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,2 +1,238 @@
|
|||||||
<h1>Welcome to SvelteKit</h1>
|
<script lang="ts">
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
import { onMount } from 'svelte';
|
||||||
|
import { animate, stagger, inView } from 'motion';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Hero section animations
|
||||||
|
const heroTextAnimation = {
|
||||||
|
initial: { opacity: 0, y: 50 },
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
delay: 0.2,
|
||||||
|
easing: [0.17, 0.55, 0.55, 1] // Smooth easing curve instead of spring
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroImageAnimation = {
|
||||||
|
initial: { opacity: 0, scale: 0.9 },
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
duration: 1.2,
|
||||||
|
easing: 'ease-out'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize hero animations
|
||||||
|
animate('.hero-text', heroTextAnimation.initial);
|
||||||
|
animate('.hero-text', heroTextAnimation.animate);
|
||||||
|
animate('.hero-image', heroImageAnimation.initial);
|
||||||
|
animate('.hero-image', heroImageAnimation.animate);
|
||||||
|
|
||||||
|
// Feature cards animation
|
||||||
|
const featureCardAnimation = {
|
||||||
|
initial: { opacity: 0, y: 30 },
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
easing: 'ease-out'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup intersection observer for feature grid
|
||||||
|
inView('.feature-grid', (info: any) => {
|
||||||
|
const cards = info.target.querySelectorAll('.feature-card');
|
||||||
|
|
||||||
|
cards.forEach((card: any, index: any) => {
|
||||||
|
animate(card,
|
||||||
|
featureCardAnimation.initial,
|
||||||
|
{
|
||||||
|
...featureCardAnimation.animate,
|
||||||
|
delay: index * 0.15 // Manual stagger implementation
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
amount: 0.2 // Trigger when 20% of element is visible
|
||||||
|
});
|
||||||
|
|
||||||
|
// CTA section animation
|
||||||
|
const ctaAnimation = {
|
||||||
|
initial: { opacity: 0, scale: 0.95 },
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.8,
|
||||||
|
easing: 'ease-out'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
inView('.cta-section', (info: any) => {
|
||||||
|
animate(info.target, ctaAnimation.initial);
|
||||||
|
animate(info.target, ctaAnimation.animate);
|
||||||
|
}, {
|
||||||
|
amount: 0.3 // Trigger when 30% of element is visible
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Boundless | Travel with Soul</title>
|
||||||
|
<meta name="description" content="Where strangers become allies and journeys create magic. Travel Together, Stay Boundless." />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="font-[Space+Grotesk] bg-base-100 overflow-hidden">
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar bg-base-100/90 backdrop-blur-sm sticky top-0 z-50 shadow-sm px-4 md:px-8">
|
||||||
|
<div class="flex-1">
|
||||||
|
<a class="btn btn-ghost text-xl gap-2 hover:-translate-y-1 transition-transform">
|
||||||
|
<span class="text-3xl">🌍</span>
|
||||||
|
<span class="font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||||
|
Boundless
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">
|
||||||
|
<ul class="menu menu-horizontal px-1 gap-4 hidden md:flex">
|
||||||
|
<li>
|
||||||
|
<a class="font-medium hover:text-primary transition-colors">
|
||||||
|
How it works
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="font-medium hover:text-primary transition-colors">
|
||||||
|
Stories
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button on:click={() => {goto('/onboarding')}} class="btn btn-primary btn-sm hover:scale-105 transition-transform">
|
||||||
|
Join Beta
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="dropdown dropdown-end md:hidden">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" /></svg>
|
||||||
|
</div>
|
||||||
|
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||||
|
<li><a>How it works</a></li>
|
||||||
|
<li><a>Stories</a></li>
|
||||||
|
<li><button on:click={() => {goto('/onboarding')}}>Join Beta</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero min-h-[90vh] relative">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-primary/10 to-secondary/10 -skew-y-3 scale-125 opacity-50"></div>
|
||||||
|
<div class="hero-content flex-col lg:flex-row-reverse gap-8 lg:gap-16 relative z-10 px-4 md:px-8">
|
||||||
|
<div class="hero-image">
|
||||||
|
<div class="mask-parallelogram relative group">
|
||||||
|
<img
|
||||||
|
src="src\lib\assets\h.png"
|
||||||
|
class="w-full max-w-2xl rounded-2xl shadow-2xl transform hover:rotate-1 transition-[transform] duration-300"
|
||||||
|
alt="Happy travelers"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-2xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-text text-center lg:text-left">
|
||||||
|
<h1 class="text-4xl md:text-6xl font-bold leading-tight mb-6">
|
||||||
|
Find Your
|
||||||
|
<span class="relative inline-block">
|
||||||
|
<span class="bg-gradient-to-r from-primary to-accent text-transparent bg-clip-text">
|
||||||
|
Wander Tribe
|
||||||
|
</span>
|
||||||
|
<div class="absolute -bottom-2 left-0 w-full h-2 bg-primary/20 -skew-x-12"></div>
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg md:text-xl mb-8 opacity-90 leading-relaxed">
|
||||||
|
Where strangers become allies and every journey leaves the world kinder.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col md:flex-row gap-4 justify-center lg:justify-start">
|
||||||
|
<button on:click={() => {goto('/onboarding')}} class="btn btn-primary px-8 gap-2 hover:scale-[1.02] transition-transform">
|
||||||
|
<span class="text-xl">✨</span>
|
||||||
|
Start Your Journey
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline btn-secondary px-8 group">
|
||||||
|
How It Works
|
||||||
|
<span class="group-hover:translate-x-1 transition-transform">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features Grid -->
|
||||||
|
<div class="container mx-auto px-4 py-16 md:py-24">
|
||||||
|
<div class="feature-grid grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||||
|
<div class="feature-card card bg-base-100 p-8 border border-base-300/20 hover:border-primary/20 transition-all transform hover:-translate-y-2">
|
||||||
|
<div class="text-6xl mb-6 animate-pulse">🗺️</div>
|
||||||
|
<h3 class="text-2xl font-bold mb-4">Community Routes</h3>
|
||||||
|
<p class="text-gray-500">Follow trails blazed by fellow wanderers</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card card bg-base-100 p-8 border border-base-300/20 hover:border-primary/20 transition-all transform hover:-translate-y-2">
|
||||||
|
<div class="text-6xl mb-6 animate-pulse">🔮</div>
|
||||||
|
<h3 class="text-2xl font-bold mb-4">Magic Detours</h3>
|
||||||
|
<p class="text-gray-500">Algorithm-curated serendipity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card card bg-base-100 p-8 border border-base-300/20 hover:border-primary/20 transition-all transform hover:-translate-y-2">
|
||||||
|
<div class="text-6xl mb-6 animate-pulse">❤️</div>
|
||||||
|
<h3 class="text-2xl font-bold mb-4">Impact Legacy</h3>
|
||||||
|
<p class="text-gray-500">Travel that gives back</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<div class="cta-section bg-gradient-to-br from-primary to-secondary text-primary-content py-16 md:py-24">
|
||||||
|
<div class="text-center max-w-2xl mx-auto px-4">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-8 leading-tight">
|
||||||
|
Ready to Travel<br>
|
||||||
|
<span class="text-4xl md:text-5xl font-black">Like You Mean It?</span>
|
||||||
|
</h2>
|
||||||
|
<button on:click={() => {goto('/onboarding')}} class="btn btn-accent btn-lg gap-2 transform hover:scale-105 transition-transform">
|
||||||
|
🚀 Claim Beta Spot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer items-center p-8 bg-neutral text-neutral-content">
|
||||||
|
<!-- ... (keep previous footer content) ... -->
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style global>
|
||||||
|
:root {
|
||||||
|
--font-family: 'Space Grotesk', system-ui;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-parallelogram {
|
||||||
|
mask-image: linear-gradient(to bottom right, transparent 2%, black 15%);
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom right, transparent 2%, black 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-pulse {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-gradient-pulse {
|
||||||
|
background-size: 200% auto;
|
||||||
|
animation: gradient-pulse 8s ease infinite;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1 +0,0 @@
|
|||||||
<a href="/demo/paraglide">paraglide</a>
|
|
@ -1,18 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { AvailableLanguageTag } from '$lib/paraglide/runtime';
|
|
||||||
import { i18n } from '$lib/i18n';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
function switchToLanguage(newLanguage: AvailableLanguageTag) {
|
|
||||||
const canonicalPath = i18n.route(page.url.pathname);
|
|
||||||
const localisedPath = i18n.resolveRoute(canonicalPath, newLanguage);
|
|
||||||
goto(localisedPath);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1>{m.hello_world({ name: 'SvelteKit User' })}</h1>
|
|
||||||
<div>
|
|
||||||
<button onclick={() => switchToLanguage('en')}>en</button>
|
|
||||||
</div>
|
|
324
frontend/src/routes/onboarding/+page.svelte
Normal file
324
frontend/src/routes/onboarding/+page.svelte
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { animate } from 'motion';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
// Form data store
|
||||||
|
let travelerData = {
|
||||||
|
name: '',
|
||||||
|
intention: '',
|
||||||
|
interests: [] as string[],
|
||||||
|
travelStyle: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
let welcomeEl: HTMLElement;
|
||||||
|
let registerEl: HTMLElement;
|
||||||
|
let intentionEl: HTMLElement;
|
||||||
|
let interestsEl: HTMLElement;
|
||||||
|
let finalEl: HTMLElement;
|
||||||
|
let confettiContainer: HTMLElement;
|
||||||
|
|
||||||
|
// Step management
|
||||||
|
let currentStep = 0;
|
||||||
|
const steps = ['welcome', 'register', 'intention', 'interests', 'final'];
|
||||||
|
const stepElements = [welcomeEl, registerEl, intentionEl, interestsEl, finalEl];
|
||||||
|
|
||||||
|
// Form options
|
||||||
|
const travelIntentions = [
|
||||||
|
'Seeking Adventure',
|
||||||
|
'Cultural Exchange',
|
||||||
|
'Personal Growth',
|
||||||
|
'Meeting People',
|
||||||
|
'Digital Nomad'
|
||||||
|
];
|
||||||
|
const travelInterests = [
|
||||||
|
'Local Food',
|
||||||
|
'Hiking',
|
||||||
|
'Photography',
|
||||||
|
'Language Exchange',
|
||||||
|
'Art & Museums',
|
||||||
|
'Sustainable Travel',
|
||||||
|
'Local Markets',
|
||||||
|
'Festivals & Events'
|
||||||
|
];
|
||||||
|
const travelStyles = [
|
||||||
|
'Spontaneous',
|
||||||
|
'Planned but Flexible',
|
||||||
|
'Off the Beaten Path',
|
||||||
|
'Mix of Everything'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Animation controller
|
||||||
|
const animateSequence = async (from: HTMLElement, to: HTMLElement) => {
|
||||||
|
from.style.zIndex = '10';
|
||||||
|
to.style.zIndex = '20';
|
||||||
|
|
||||||
|
const fromAnimation = animate(
|
||||||
|
from,
|
||||||
|
{ y: [0, '-100vh'] },
|
||||||
|
{ duration: 0.7, easing: [0.4, 0, 0.2, 1] }
|
||||||
|
);
|
||||||
|
const toAnimation = animate(
|
||||||
|
to,
|
||||||
|
{ y: ['100vh', 0] },
|
||||||
|
{ duration: 0.7, easing: [0.4, 0, 0.2, 1] }
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([fromAnimation, toAnimation]);
|
||||||
|
from.style.zIndex = '0';
|
||||||
|
currentStep = steps.indexOf(to.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInterestToggle = (e: MouseEvent, interest: string) => {
|
||||||
|
if (travelerData.interests.includes(interest)) {
|
||||||
|
travelerData.interests = travelerData.interests.filter((i) => i !== interest);
|
||||||
|
} else if (travelerData.interests.length < 3) {
|
||||||
|
travelerData.interests = [...travelerData.interests, interest];
|
||||||
|
}
|
||||||
|
animate(e.currentTarget as HTMLElement, { scale: [1, 1.1, 1] }, { duration: 0.3 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = () => {
|
||||||
|
return (
|
||||||
|
travelerData.name.length >= 2 &&
|
||||||
|
travelerData.intention &&
|
||||||
|
travelerData.interests.length > 0 &&
|
||||||
|
travelerData.travelStyle
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createConfetti = () => {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const confetti = document.createElement('div');
|
||||||
|
confetti.className = 'absolute w-2 h-2 bg-white rounded-full';
|
||||||
|
confetti.style.left = `${Math.random() * 100}%`;
|
||||||
|
confetti.style.top = `${Math.random() * 100}%`;
|
||||||
|
confettiContainer.appendChild(confetti);
|
||||||
|
|
||||||
|
animate(
|
||||||
|
confetti,
|
||||||
|
{
|
||||||
|
x: Math.random() * 400 - 200,
|
||||||
|
y: Math.random() * -400 - 100,
|
||||||
|
opacity: [1, 0],
|
||||||
|
scale: [1, 1.5]
|
||||||
|
},
|
||||||
|
{ duration: 1.5, easing: 'ease-out' }
|
||||||
|
).then(() => confetti.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
goto('/home');
|
||||||
|
}, 750);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
animate(welcomeEl, { scale: [0.8, 1], opacity: [0, 1] }, { duration: 0.5 });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Progress Indicator -->
|
||||||
|
<div class="absolute top-6 z-30 w-full px-8">
|
||||||
|
<div class="h-1.5 rounded-full bg-white/20">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-white/80 transition-all duration-500"
|
||||||
|
style={`width: ${(currentStep / (steps.length - 1)) * 100}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Welcome Screen -->
|
||||||
|
<div
|
||||||
|
bind:this={welcomeEl}
|
||||||
|
id="welcome"
|
||||||
|
class="gradient-animate fixed inset-0 flex h-screen w-screen flex-col items-center justify-center bg-gradient-to-br from-pink-400 via-purple-400 to-indigo-400"
|
||||||
|
>
|
||||||
|
{#each Array(12) as _, i}
|
||||||
|
<div
|
||||||
|
class="floating-shape absolute h-8 w-8 rounded-full bg-white/10"
|
||||||
|
style={`left: ${Math.random() * 100}%; animation-delay: ${Math.random() * 2}s;`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<h1 class="mb-4 animate-[slideDown_0.5s] text-5xl font-bold text-white">Boundless 🌍✨</h1>
|
||||||
|
<button
|
||||||
|
on:click={() => animateSequence(welcomeEl, registerEl)}
|
||||||
|
class="animate-[bounce_2s_infinite] rounded-full bg-white/20 px-8 py-3 text-white backdrop-blur-sm transition-all hover:bg-white/30"
|
||||||
|
>
|
||||||
|
Start Exploring →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name Input -->
|
||||||
|
<div
|
||||||
|
bind:this={registerEl}
|
||||||
|
id="register"
|
||||||
|
class="gradient-animate fixed inset-0 flex h-screen w-screen translate-y-full flex-col items-center justify-center bg-gradient-to-br from-purple-400 via-pink-400 to-rose-400"
|
||||||
|
>
|
||||||
|
<div class="space-y-8 text-center">
|
||||||
|
<h2 class="text-3xl font-semibold text-white">What's your travel name?</h2>
|
||||||
|
<label for="nameInput" class="sr-only">Enter your travel name</label>
|
||||||
|
<input
|
||||||
|
id="nameInput"
|
||||||
|
bind:value={travelerData.name}
|
||||||
|
class="w-64 rounded-full bg-white/20 px-6 py-3 text-center text-white placeholder-white/70 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-white/50"
|
||||||
|
type="text"
|
||||||
|
placeholder="Adventure Seeker"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
on:click={() => animateSequence(registerEl, intentionEl)}
|
||||||
|
class="rounded-full bg-white/20 px-8 py-3 text-white backdrop-blur-sm transition-all hover:bg-white/30"
|
||||||
|
disabled={!travelerData.name}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Travel Intention -->
|
||||||
|
<div
|
||||||
|
bind:this={intentionEl}
|
||||||
|
id="intention"
|
||||||
|
class="gradient-animate fixed inset-0 flex h-screen w-screen translate-y-full flex-col items-center justify-center bg-gradient-to-br from-rose-400 via-orange-400 to-amber-400"
|
||||||
|
>
|
||||||
|
<div class="space-y-8 text-center">
|
||||||
|
<h2 class="text-3xl font-semibold text-white">Why do you wander?</h2>
|
||||||
|
<div class="grid w-72 grid-cols-1 gap-3">
|
||||||
|
{#each travelIntentions as intention}
|
||||||
|
<button
|
||||||
|
on:click={(e) => {
|
||||||
|
travelerData.intention = intention;
|
||||||
|
animate(e.currentTarget, { scale: [1, 0.95, 1] }, { duration: 0.3 });
|
||||||
|
}}
|
||||||
|
class="rounded-full bg-white/20 px-6 py-3 text-white backdrop-blur-sm transition-all hover:bg-white/30 {travelerData.intention ===
|
||||||
|
intention
|
||||||
|
? '!bg-white/40 ring-2 ring-white'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{intention}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
on:click={() => animateSequence(intentionEl, interestsEl)}
|
||||||
|
class="rounded-full bg-white/20 px-8 py-3 text-white backdrop-blur-sm transition-all hover:bg-white/30"
|
||||||
|
disabled={!travelerData.intention}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interests Selection -->
|
||||||
|
<div
|
||||||
|
bind:this={interestsEl}
|
||||||
|
id="interests"
|
||||||
|
class="gradient-animate fixed inset-0 flex h-screen w-screen translate-y-full flex-col items-center justify-center bg-gradient-to-br from-purple-400 via-pink-400 to-gray-400"
|
||||||
|
>
|
||||||
|
<div class="space-y-8 text-center">
|
||||||
|
<h2 class="text-3xl font-semibold text-white">Pick your passions</h2>
|
||||||
|
<div class="grid w-80 grid-cols-2 gap-3">
|
||||||
|
{#each travelInterests as interest}
|
||||||
|
<button
|
||||||
|
on:click={(e) => handleInterestToggle(e, interest)}
|
||||||
|
class="rounded-full bg-white/20 px-4 py-2 text-sm text-white backdrop-blur-sm transition-all hover:bg-white/30
|
||||||
|
{travelerData.interests.includes(interest) ? '!bg-white/40 ring-2 ring-white' : ''}"
|
||||||
|
aria-pressed={travelerData.interests.includes(interest)}
|
||||||
|
>
|
||||||
|
{interest}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
on:click={() => animateSequence(interestsEl, finalEl)}
|
||||||
|
class="rounded-full bg-white/20 px-8 py-3 text-white backdrop-blur-sm transition-all hover:bg-white/30"
|
||||||
|
disabled={travelerData.interests.length === 0}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Final Step -->
|
||||||
|
<div
|
||||||
|
bind:this={finalEl}
|
||||||
|
id="final"
|
||||||
|
class="gradient-animate fixed inset-0 flex h-screen w-screen translate-y-full flex-col items-center justify-center bg-gradient-to-br from-lime-400 via-emerald-400 to-cyan-400"
|
||||||
|
>
|
||||||
|
<div class="space-y-8 text-center">
|
||||||
|
<h2 class="text-3xl font-semibold text-white">Travel style?</h2>
|
||||||
|
<div class="grid w-72 grid-cols-1 gap-3">
|
||||||
|
{#each travelStyles as style}
|
||||||
|
<button
|
||||||
|
on:click={(e) => {
|
||||||
|
travelerData.travelStyle = style;
|
||||||
|
animate(e.currentTarget, { scale: [1, 0.95, 1] }, { duration: 0.3 });
|
||||||
|
}}
|
||||||
|
class="rounded-full bg-white/20 px-6 py-3 text-white backdrop-blur-sm transition-all hover:bg-white/30 {travelerData.travelStyle ===
|
||||||
|
style
|
||||||
|
? '!bg-white/40 ring-2 ring-white'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{style}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div bind:this={confettiContainer} class="pointer-events-none absolute inset-0" />
|
||||||
|
<button
|
||||||
|
on:click={() => createConfetti()}
|
||||||
|
class="rounded-full bg-white/20 px-8 py-3 text-white ring-2 ring-white/50 backdrop-blur-sm transition-all hover:bg-white/30 hover:ring-white/80 {isValid()
|
||||||
|
? 'animate-pulse'
|
||||||
|
: ''}"
|
||||||
|
disabled={!isValid()}
|
||||||
|
>
|
||||||
|
Start Exploring 🌟
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style global>
|
||||||
|
@keyframes gradient-pan {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-animate {
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: gradient-pan 10s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
transform: translateY(100vh) rotate(0deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-100vh) rotate(360deg);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-shape {
|
||||||
|
animation: float 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
123
frontend/src/routes/signin/+page.svelte
Normal file
123
frontend/src/routes/signin/+page.svelte
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<script>
|
||||||
|
import { animate, inView } from 'motion';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
const planePath = "M13 11.5V7.5L16 8.5L19 4.5L22 8.5L25 7.5V11.5L22 12.5L25 16.5L22 20.5L19 16.5L16 20.5L13 16.5V11.5Z";
|
||||||
|
|
||||||
|
let email = '';
|
||||||
|
let password = '';
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
// Add your signin logic
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatPlane() {
|
||||||
|
animate("#paper-plane",
|
||||||
|
// @ts-ignore
|
||||||
|
{ y: [-10, 10] },
|
||||||
|
{ duration: 2, repeat: Infinity, direction: "alternate" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Page load animations
|
||||||
|
animate("#form-container",
|
||||||
|
{ y: [50, 0], opacity: [0, 1] },
|
||||||
|
{ duration: 0.8 }
|
||||||
|
);
|
||||||
|
|
||||||
|
animate("#paper-plane",
|
||||||
|
{ x: [-100, 0], rotate: [-45, 0] },
|
||||||
|
{ duration: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
floatPlane();
|
||||||
|
|
||||||
|
animate(".route-line",
|
||||||
|
{ strokeDashoffset: [100, 0] },
|
||||||
|
{ duration: 2, delay: 0.5 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-base-200 relative overflow-hidden">
|
||||||
|
<!-- Background Elements -->
|
||||||
|
<div class="absolute inset-0 opacity-10">
|
||||||
|
<!-- Add your map/route background SVG here -->
|
||||||
|
<svg class="w-full h-full">
|
||||||
|
<path
|
||||||
|
d="M100,200 Q250,150 400,200"
|
||||||
|
class="route-line fill-none stroke-primary stroke-2"
|
||||||
|
style="stroke-dasharray: 100px;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex items-center justify-center min-h-screen">
|
||||||
|
<!-- Paper Plane Decor -->
|
||||||
|
<div id="paper-plane" class="absolute left-1/4 top-1/3 opacity-50">
|
||||||
|
<svg class="w-32 h-32 text-primary" viewBox="0 0 32 32">
|
||||||
|
<path fill="currentColor" d="{planePath}" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Container -->
|
||||||
|
<div id="form-container" class="card bg-base-100 shadow-xl w-96 relative z-10">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-3xl mb-6">Welcome Back, Explorer! ✈️</h2>
|
||||||
|
|
||||||
|
<form on:submit|preventDefault={handleSubmit}>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Email</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
placeholder="your.email@adventure.com"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
placeholder="••••••••"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary mt-6 w-full"
|
||||||
|
>
|
||||||
|
Let's Travel Together! 🌍
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider my-6">New Adventurer?</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/signup"
|
||||||
|
class="btn btn-outline btn-secondary w-full"
|
||||||
|
>
|
||||||
|
Create Travel Profile 🧳
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Add custom paper texture if needed */
|
||||||
|
.paper-texture {
|
||||||
|
background-image: url('paper-texture.png');
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
}
|
||||||
|
</style>
|
@ -10,5 +10,16 @@ export default {
|
|||||||
extend: {}
|
extend: {}
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [typography, forms, containerQueries]
|
daisyui: {
|
||||||
|
themes: ["fantasy"], // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"]
|
||||||
|
darkTheme: "dark", // name of one of the included themes for dark mode
|
||||||
|
base: true, // applies background color and foreground color for root element by default
|
||||||
|
styled: true, // include daisyUI colors and design decisions for all components
|
||||||
|
utils: true, // adds responsive and modifier utility classes
|
||||||
|
prefix: "", // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors)
|
||||||
|
logs: true, // Shows info about daisyUI version and used config in the console when building your CSS
|
||||||
|
themeRoot: ":root", // The element that receives theme color CSS variables
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [typography, forms, containerQueries, require('daisyui'),]
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
Loading…
Reference in New Issue
Block a user