Files
jellytau/src/lib/components/FavoriteButton.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

182 lines
5.0 KiB
Svelte

<!-- TRACES: UR-017, UR-068 | DR-021, DR-119 -->
<script lang="ts">
import { toggleFavorite } from "$lib/services/favorites";
import { haptics } from "$lib/utils/haptics";
import { toast } from "$lib/stores/toast";
import { favoriteOverrides } from "$lib/stores/favorites";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("FavoriteButton");
interface Props {
itemId: string;
isFavorite?: boolean;
size?: "sm" | "md" | "lg";
className?: string;
/**
* "button" (default) is the standalone control used in header/hero rows;
* "overlay" is the artwork corner variant used on cards, which needs its
* own scrim to stay legible over any poster.
*/
variant?: "button" | "overlay";
/** Stop the click reaching a parent card/row that would navigate or play. */
stopPropagation?: boolean;
}
let {
itemId,
isFavorite = $bindable(false),
size = "md",
className = "",
variant = "button",
stopPropagation = false,
}: Props = $props();
let isLoading = $state(false);
let isAnimating = $state(false);
// A toggle from any other surface (or the backend's `favorites-changed`
// refresh) wins over the prop we were mounted with — otherwise a heart tapped
// on a card would still read empty on the detail page behind it.
$effect(() => {
const override = $favoriteOverrides.get(itemId);
if (override !== undefined && override !== isFavorite) {
isFavorite = override;
}
});
const sizeClasses = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
async function handleToggle(event: MouseEvent) {
// On a card the heart sits inside a clickable tile; without this, hearting
// an item would also open (or play) it.
if (stopPropagation) {
event.stopPropagation();
event.preventDefault();
}
if (isLoading) return;
isLoading = true;
isAnimating = true;
try {
const newValue = await toggleFavorite(itemId, isFavorite);
isFavorite = newValue;
// Haptic feedback
if (newValue) {
haptics.success();
toast.show("Added to favorites", "success", 1500);
} else {
haptics.tap();
toast.show("Removed from favorites", "info", 1500);
}
// Reset animation after it completes
setTimeout(() => {
isAnimating = false;
}, 600);
} catch (error) {
log.error("Failed to toggle favorite:", error);
toast.show("Failed to update favorites", "error");
isAnimating = false;
} finally {
isLoading = false;
}
}
// Compute button classes
const buttonClass = $derived.by(() => {
const baseClasses =
variant === "overlay"
? "p-1.5 rounded-full transition-all bg-black/50 backdrop-blur-sm hover:bg-black/70"
: "p-2 rounded-full transition-all";
const colorClasses = isFavorite
? "text-red-500 hover:text-red-400"
: variant === "overlay"
? "text-white/80 hover:text-white"
: "text-gray-400 hover:text-white";
const loadingClasses = isLoading ? "opacity-50 cursor-wait" : "";
return `${baseClasses} ${colorClasses} ${loadingClasses} ${className}`.trim();
});
// Compute SVG classes
const svgClass = $derived.by(() => {
const sizeClass = sizeClasses[size];
return sizeClass;
});
// Inline animation styles
const buttonStyle = $derived(isAnimating ? "animation: bounce-once 0.6s ease-in-out;" : "");
const svgStyle = $derived(
isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "",
);
</script>
<button
onclick={handleToggle}
disabled={isLoading}
class={buttonClass}
style={buttonStyle}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
{#if isFavorite}
<!-- Filled heart with scale animation -->
<svg class={svgClass} style={svgStyle} fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<!-- Outline heart -->
<svg
class={sizeClasses[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
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>
{/if}
</button>
<style>
@keyframes heart-pop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
@keyframes bounce-once {
0%,
100% {
transform: translateY(0);
}
25% {
transform: translateY(-8px);
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(-4px);
}
}
</style>