TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
178 lines
5.0 KiB
Svelte
178 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>
|