prototype ig
This commit is contained in:
parent
398d62abd0
commit
38c19728e8
1680
frontend/package-lock.json
generated
1680
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -20,6 +20,7 @@
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
@ -27,6 +28,7 @@
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"daisyui": "^4.12.23",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
@ -43,6 +45,9 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inlang/paraglide-sveltekit": "^0.15.5"
|
||||
"@capacitor/cli": "^7.0.1",
|
||||
"@capacitor/core": "^7.0.1",
|
||||
"@inlang/paraglide-sveltekit": "^0.15.5",
|
||||
"motion": "^12.0.6"
|
||||
}
|
||||
}
|
||||
|
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,11 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@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;
|
||||
}
|
25
frontend/src/routes/(authed)/+layout.svelte
Normal file
25
frontend/src/routes/(authed)/+layout.svelte
Normal file
@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { i18n } from '$lib/i18n';
|
||||
import Nav from '$lib/Nav.svelte';
|
||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<ParaglideJS {i18n}>
|
||||
<div class="fillscreen">
|
||||
{@render children()}
|
||||
</div>
|
||||
<Nav
|
||||
showLabels={false}
|
||||
animate={true}
|
||||
darkMode={false}
|
||||
/>
|
||||
</ParaglideJS>
|
||||
|
||||
|
||||
<style>
|
||||
.fillscreen{
|
||||
height: calc(100svh - 64px);
|
||||
}
|
||||
</style>
|
0
frontend/src/routes/(authed)/add/+page.svelte
Normal file
0
frontend/src/routes/(authed)/add/+page.svelte
Normal file
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 bg-base-200">
|
||||
<!-- 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-r 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-base-200">
|
||||
<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>
|
43
frontend/src/routes/(authed)/home/+page.svelte
Normal file
43
frontend/src/routes/(authed)/home/+page.svelte
Normal file
@ -0,0 +1,43 @@
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
// You can add your data or functions here if needed
|
||||
const cards = [
|
||||
{ title: 'Swipe', description: 'swipe and make friends', btn: 'lesgooo' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4">
|
||||
<!-- Hero Section -->
|
||||
<div class="text-center py-12">
|
||||
<h1 class="text-4xl font-bold mb-4">Welcome user</h1>
|
||||
</div>
|
||||
|
||||
<!-- Card Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each cards as card}
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-200">
|
||||
<figure class="px-6 pt-6">
|
||||
<div class="h-32 w-full bg-base-200 rounded-lg flex items-center justify-center">
|
||||
<span class="text-4xl">😎</span> <!-- Replace with actual icon/image -->
|
||||
</div>
|
||||
</figure>
|
||||
<div class="card-body items-center text-center">
|
||||
<h2 class="card-title">{card.title}</h2>
|
||||
<p class="text-gray-600">{card.description}</p>
|
||||
<div class="card-actions mt-4">
|
||||
<button on:click={() => {goto('/swipe')}} class="btn btn-primary">{card.btn}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style global>
|
||||
/* Optional custom styles */
|
||||
:global(body) {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
28
frontend/src/routes/(authed)/notifications/+page.svelte
Normal file
28
frontend/src/routes/(authed)/notifications/+page.svelte
Normal file
@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { Notification } from '$lib/types';
|
||||
|
||||
export let notifications: Notification[];
|
||||
</script>
|
||||
|
||||
<div class="p-4 max-w-3xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Notifications</h1>
|
||||
<div class="space-y-4">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<div
|
||||
class="p-4 rounded-lg {!notification.read ? 'bg-primary/10 border-l-4 border-primary' : 'bg-base-200'}"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-medium">{notification.content}</p>
|
||||
<p class="text-sm text-base-content/50 mt-1">
|
||||
{notification.timestamp}
|
||||
</p>
|
||||
</div>
|
||||
{#if !notification.read}
|
||||
<span class="badge badge-primary badge-sm">New</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
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="min-h-screen bg-base-200 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 w-fit max-w-3xl 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>
|
288
frontend/src/routes/(authed)/swipe/+page.svelte
Normal file
288
frontend/src/routes/(authed)/swipe/+page.svelte
Normal file
@ -0,0 +1,288 @@
|
||||
<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;
|
||||
};
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
let stack: any[] = profiles;
|
||||
let connections: Profile[] = [];
|
||||
let dragging: Profile | null = null;
|
||||
let dragStartOffset = { x: 0, y: 0 };
|
||||
|
||||
let dragCoords = spring({ x: 0, y: 0 }, {
|
||||
stiffness: 0.2,
|
||||
damping: 0.7
|
||||
});
|
||||
|
||||
const calculateConnectionPath = (connection: Profile) => {
|
||||
const start = connection.location || { x: 500, y: 250 };
|
||||
const end = { x: window.innerWidth - 100, y: window.innerHeight - 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}`;
|
||||
};
|
||||
|
||||
const handleAction = (action: 'skip' | 'decide' | 'add') => {
|
||||
if (stack.length === 0) return;
|
||||
|
||||
const profile = stack[currentIndex];
|
||||
|
||||
if (action === 'add') {
|
||||
connections = [...connections, { ...profile, location: { ...$dragCoords } }];
|
||||
|
||||
const suitcase = document.querySelector('#suitcase');
|
||||
const profileCard = document.querySelector(`#profile-${profile.id}`);
|
||||
|
||||
if (suitcase && profileCard) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
stack = stack.filter((_, i) => i !== currentIndex);
|
||||
currentIndex = Math.min(currentIndex, stack.length - 1);
|
||||
};
|
||||
|
||||
const startDrag = (event: MouseEvent, profile: Profile) => {
|
||||
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) {
|
||||
if (dragging) {
|
||||
dragCoords.set({
|
||||
x: event.clientX - dragStartOffset.x,
|
||||
y: event.clientY - dragStartOffset.y
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
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) {
|
||||
return !(rect1.right < rect2.left ||
|
||||
rect1.left > rect2.right ||
|
||||
rect1.bottom < rect2.top ||
|
||||
rect1.top > rect2.bottom);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent, profile: Profile) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:mousemove={handleMouseMove} on:mouseup={handleMouseUp} />
|
||||
|
||||
<div class="min-h-screen bg-stone-100 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}
|
||||
<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)}>
|
||||
<!-- Profile Card -->
|
||||
<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}
|
||||
<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 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>
|
@ -1,2 +1,238 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<script lang="ts">
|
||||
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>
|
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">🌍 WanderLink</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>
|
@ -10,5 +10,16 @@ export default {
|
||||
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;
|
||||
|
Loading…
Reference in New Issue
Block a user