Repository Public
1"use client";23import Link from "next/link";4import { useEffect, useState } from "react";56export interface ActionLabels { like: string; liked: string; save: string; saved: string; share: string; copied: string }7const SAVED = "clip:saved", LIKED = "clip:liked";8const readSet = (k: string): string[] => { try { return JSON.parse(localStorage.getItem(k) ?? "[]"); } catch { return []; } };9const writeSet = (k: string, v: string[]) => { try { localStorage.setItem(k, JSON.stringify(v.slice(-200))); } catch { /* private mode */ } };10export const savedIds = () => readSet(SAVED);1112/** Privacy-respecting analytics: one anonymous event per film and kind, no identifier stored in the browser. */13export function track(filmId: string, kind: "view" | "complete" | "share", locale: string) {14 try { void fetch("/api/events", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filmId, kind, locale }), keepalive: true }); } catch { /* ignore */ }15}1617const P = {18 heart: "M12 20s-7-4.4-7-9.6A4 4 0 0 1 12 8a4 4 0 0 1 7 2.4C19 15.6 12 20 12 20z",19 mark: "M7 4h10v16l-5-3.6L7 20z",20 share: "M12 15V4M8 8l4-4 4 4M5 12v7h14v-7",21 info: "M12 11v6M12 7.5v.5M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z",22};23const Glyph = ({ d, fill }: { d: string; fill?: boolean }) => (24 <svg width="22" height="22" viewBox="0 0 24 24" fill={fill ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>25);2627export function FilmActions({ film, labels, layout, detailsHref, detailsLabel }: { film: { id: string; slug: string; title: string; likes: number }; labels: ActionLabels; layout: "row" | "column"; detailsHref?: string; detailsLabel?: string }) {28 const [liked, setLiked] = useState(false);29 const [saved, setSaved] = useState(false);30 const [likes, setLikes] = useState(film.likes);31 const [copied, setCopied] = useState(false);32 useEffect(() => { setLiked(readSet(LIKED).includes(film.id)); setSaved(readSet(SAVED).includes(film.id)); }, [film.id]);3334 const like = async () => {35 const next = !liked;36 setLiked(next); setLikes((n) => Math.max(0, n + (next ? 1 : -1)));37 writeSet(LIKED, next ? [...readSet(LIKED), film.id] : readSet(LIKED).filter((x) => x !== film.id));38 try { const r = await fetch(`/api/films/${film.slug}/react`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ like: next }) }); if (r.ok) setLikes((await r.json()).likes); } catch { /* optimistic */ }39 };40 const save = () => { const next = !saved; setSaved(next); writeSet(SAVED, next ? [...readSet(SAVED), film.id] : readSet(SAVED).filter((x) => x !== film.id)); };41 const share = async () => {42 const url = `${location.origin}${detailsHref ?? location.pathname}`;43 try {44 if (navigator.share) await navigator.share({ title: film.title, url });45 else { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 1800); }46 track(film.id, "share", document.documentElement.lang);47 } catch { /* cancelled */ }48 };49 const col = layout === "column";50 const cls = col ? "grid h-11 w-11 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm" : "btn btn-ghost btn-sm";51 return (52 <div className={col ? "flex flex-col items-center gap-3" : "flex flex-wrap gap-2"}>53 <button type="button" onClick={like} aria-pressed={liked} aria-label={`${liked ? labels.liked : labels.like} (${likes})`} className={`${cls} ${liked ? "!text-[var(--color-accent)]" : ""}`}>54 <Glyph d={P.heart} fill={liked} />{!col && <span>{liked ? labels.liked : labels.like} · {likes}</span>}55 </button>56 <button type="button" onClick={save} aria-pressed={saved} aria-label={saved ? labels.saved : labels.save} className={`${cls} ${saved ? "!text-[var(--color-accent)]" : ""}`}>57 <Glyph d={P.mark} fill={saved} />{!col && <span>{saved ? labels.saved : labels.save}</span>}58 </button>59 <button type="button" onClick={share} aria-label={labels.share} className={cls}><Glyph d={P.share} />{!col && <span aria-live="polite">{copied ? labels.copied : labels.share}</span>}</button>60 {detailsHref && col && <Link href={detailsHref} aria-label={detailsLabel} className={cls}><Glyph d={P.info} /></Link>}61 {detailsHref && !col && <Link href={detailsHref} className="btn btn-primary btn-sm">{detailsLabel}</Link>}62 </div>63 );64}65