swipeable interface
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@ WORKDIR /src
|
|||||||
# Deps Stage
|
# Deps Stage
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
|
|
||||||
RUN pnpm i --frozen-lockfile
|
RUN npm i
|
||||||
|
|
||||||
# Build Stage
|
# Build Stage
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import WeatherCard from "./WeatherCard.svelte";
|
||||||
|
import type { WeatherPeriod } from "$lib/types/weather.js";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
periods: WeatherPeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { periods }: Props = $props();
|
||||||
|
|
||||||
|
let cardStack = $state([...periods]);
|
||||||
|
let currentCardIndex = $state(0);
|
||||||
|
let isDragging = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let currentX = 0;
|
||||||
|
let currentY = 0;
|
||||||
|
let topCardRef: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD = 100;
|
||||||
|
const ROTATION_FACTOR = 0.1;
|
||||||
|
const MAX_ROTATION = 15;
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
if (!topCardRef) return;
|
||||||
|
|
||||||
|
startX = e.touches[0].clientX;
|
||||||
|
startY = e.touches[0].clientY;
|
||||||
|
isDragging = true;
|
||||||
|
|
||||||
|
// Enable hardware acceleration and disable transitions smoothly
|
||||||
|
topCardRef.style.willChange = "transform, opacity";
|
||||||
|
topCardRef.style.transition = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging || !topCardRef) return;
|
||||||
|
|
||||||
|
currentX = e.touches[0].clientX;
|
||||||
|
currentY = e.touches[0].clientY;
|
||||||
|
|
||||||
|
const deltaX = currentX - startX;
|
||||||
|
const deltaY = currentY - startY;
|
||||||
|
const rotation = Math.min(
|
||||||
|
Math.max(deltaX * ROTATION_FACTOR, -MAX_ROTATION),
|
||||||
|
MAX_ROTATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use translate3d for hardware acceleration
|
||||||
|
topCardRef.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0) rotate(${rotation}deg)`;
|
||||||
|
topCardRef.style.opacity = `${1 - Math.abs(deltaX) / 300}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd() {
|
||||||
|
if (!isDragging || !topCardRef) return;
|
||||||
|
|
||||||
|
isDragging = false;
|
||||||
|
const deltaX = currentX - startX;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > SWIPE_THRESHOLD) {
|
||||||
|
// Card swiped away - animate it off screen
|
||||||
|
const direction = deltaX > 0 ? 1 : -1;
|
||||||
|
topCardRef.style.transition =
|
||||||
|
"transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
|
||||||
|
topCardRef.style.transform = `translate3d(${direction * window.innerWidth}px, ${currentY - startY}px, 0) rotate(${direction * MAX_ROTATION * 2}deg)`;
|
||||||
|
topCardRef.style.opacity = "0";
|
||||||
|
|
||||||
|
// Remove the card after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
removeTopCard();
|
||||||
|
}, 400);
|
||||||
|
} else {
|
||||||
|
// Snap back to center with smooth spring-like animation
|
||||||
|
topCardRef.style.transition =
|
||||||
|
"transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease-out";
|
||||||
|
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
|
||||||
|
topCardRef.style.opacity = "1";
|
||||||
|
|
||||||
|
// Clean up will-change after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
if (topCardRef) {
|
||||||
|
topCardRef.style.willChange = "auto";
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTopCard() {
|
||||||
|
if (cardStack.length === 0) return;
|
||||||
|
|
||||||
|
// Remove the top card and add it to the bottom of the stack
|
||||||
|
const removedCard = cardStack[currentCardIndex];
|
||||||
|
cardStack = [
|
||||||
|
...cardStack.slice(0, currentCardIndex),
|
||||||
|
...cardStack.slice(currentCardIndex + 1),
|
||||||
|
removedCard,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reset the top card reference
|
||||||
|
if (topCardRef) {
|
||||||
|
topCardRef.style.willChange = "auto";
|
||||||
|
topCardRef.style.transition = "none";
|
||||||
|
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
|
||||||
|
topCardRef.style.opacity = "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToNow() {
|
||||||
|
if (isDragging) return; // Don't jump while dragging
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
|
||||||
|
// Find the period that represents the current time or closest future time
|
||||||
|
const nowIndex = periods.findIndex(period => {
|
||||||
|
const periodStart = new Date(period.startTime);
|
||||||
|
return periodStart.getHours() >= currentHour;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nowIndex !== -1) {
|
||||||
|
// Reorder the card stack to put the "now" period at the front
|
||||||
|
const beforeNow = periods.slice(0, nowIndex);
|
||||||
|
const fromNow = periods.slice(nowIndex);
|
||||||
|
cardStack = [...fromNow, ...beforeNow];
|
||||||
|
currentCardIndex = 0;
|
||||||
|
|
||||||
|
// Wait a frame to ensure DOM updates, then reset the top card
|
||||||
|
setTimeout(() => {
|
||||||
|
if (topCardRef) {
|
||||||
|
topCardRef.style.willChange = "auto";
|
||||||
|
topCardRef.style.transition = "none";
|
||||||
|
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
|
||||||
|
topCardRef.style.opacity = "1";
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mouse events for desktop testing
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (!topCardRef) return;
|
||||||
|
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
isDragging = true;
|
||||||
|
|
||||||
|
// Enable hardware acceleration and disable transitions smoothly
|
||||||
|
topCardRef.style.willChange = "transform, opacity";
|
||||||
|
topCardRef.style.transition = "none";
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging || !topCardRef) return;
|
||||||
|
|
||||||
|
currentX = e.clientX;
|
||||||
|
currentY = e.clientY;
|
||||||
|
|
||||||
|
const deltaX = currentX - startX;
|
||||||
|
const deltaY = currentY - startY;
|
||||||
|
const rotation = Math.min(
|
||||||
|
Math.max(deltaX * ROTATION_FACTOR, -MAX_ROTATION),
|
||||||
|
MAX_ROTATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use translate3d for hardware acceleration
|
||||||
|
topCardRef.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0) rotate(${rotation}deg)`;
|
||||||
|
topCardRef.style.opacity = `${1 - Math.abs(deltaX) / 300}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
if (!isDragging || !topCardRef) return;
|
||||||
|
|
||||||
|
isDragging = false;
|
||||||
|
const deltaX = currentX - startX;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > SWIPE_THRESHOLD) {
|
||||||
|
// Card swiped away
|
||||||
|
const direction = deltaX > 0 ? 1 : -1;
|
||||||
|
topCardRef.style.transition =
|
||||||
|
"transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
|
||||||
|
topCardRef.style.transform = `translate3d(${direction * window.innerWidth}px, ${currentY - startY}px, 0) rotate(${direction * MAX_ROTATION * 2}deg)`;
|
||||||
|
topCardRef.style.opacity = "0";
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
removeTopCard();
|
||||||
|
}, 400);
|
||||||
|
} else {
|
||||||
|
// Snap back with smooth spring-like animation
|
||||||
|
topCardRef.style.transition =
|
||||||
|
"transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease-out";
|
||||||
|
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
|
||||||
|
topCardRef.style.opacity = "1";
|
||||||
|
|
||||||
|
// Clean up will-change after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
if (topCardRef) {
|
||||||
|
topCardRef.style.willChange = "auto";
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Desktop view - keep original grid layout -->
|
||||||
|
<div class="hidden md:grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{#each periods as period (period.number)}
|
||||||
|
<WeatherCard {period} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile view - card stack -->
|
||||||
|
<div class="md:hidden">
|
||||||
|
<div class="relative h-[360px] flex items-center justify-center">
|
||||||
|
{#each cardStack.slice(0, 3) as period, index (period.number)}
|
||||||
|
<div
|
||||||
|
class="absolute w-full max-w-sm"
|
||||||
|
style="
|
||||||
|
z-index: {3 - index};
|
||||||
|
transform: translate3d({index * (index % 2 === 0 ? 6 : -6)}px, {index * 8}px, 0) rotate({index * (index % 2 === 0 ? 2 : -2)}deg) scale({1 - index * 0.03});
|
||||||
|
will-change: {index === 0 ? 'transform, opacity' : 'auto'};
|
||||||
|
opacity: {1 - index * 0.15};
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{#if index === 0}
|
||||||
|
<div
|
||||||
|
bind:this={topCardRef}
|
||||||
|
class="cursor-grab active:cursor-grabbing"
|
||||||
|
ontouchstart={handleTouchStart}
|
||||||
|
ontouchmove={handleTouchMove}
|
||||||
|
ontouchend={handleTouchEnd}
|
||||||
|
onmousedown={handleMouseDown}
|
||||||
|
role="button"
|
||||||
|
aria-label="Swipe to dismiss card"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<WeatherCard {period} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<WeatherCard {period} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jump to now button -->
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<button
|
||||||
|
class="text-sm text-blue-500 hover:text-blue-700 underline cursor-pointer"
|
||||||
|
onclick={jumpToNow}
|
||||||
|
>
|
||||||
|
jump to now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<div class="text-center mt-2 text-xs text-muted-foreground">
|
||||||
|
Swipe left or right to dismiss
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import WeatherGrid from '$lib/components/WeatherGrid.svelte';
|
import SwipeableWeatherGrid from '$lib/components/SwipeableWeatherGrid.svelte';
|
||||||
import LocationButton from '$lib/components/LocationButton.svelte';
|
import LocationButton from '$lib/components/LocationButton.svelte';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
||||||
import { WeatherService } from '$lib/services/weather.js';
|
import { WeatherService } from '$lib/services/weather.js';
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<WeatherGrid periods={forecast.properties.periods} />
|
<SwipeableWeatherGrid periods={forecast.properties.periods} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user