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.
182 lines
5.0 KiB
Svelte
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>
|