Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion i18n/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -9140,7 +9140,7 @@
"The most-watched movies and series on {name} right now in {region}.": "أكثر الأفلام والمسلسلات مشاهدةً على {name} الآن في {region}.",
"The most-watched movies and series on {service} right now.": "الأفلام والمسلسلات الأكثر مشاهدة على {service} الآن.",
"The myth, reconsidered": "الأسطورة، يُعاد النظر فيها",
"The New, In Cinema, Rerun, and Awards chips. Turn off for a cleaner grid. Score chips are separate, below.": "شارات \"جديد\" و\"في السينما\" و\"إعادة عرض\" و\"الجوائز\". عطّلها للحصول على شبكة أنظف. شارات الدرجات منفصلة، في الأسفل.",
"The New, In Cinema, Rerun. Turn off for a cleaner grid. Score chips are separate, below.": "شارات \"جديد\" و\"في السينما\" و\"إعادة عرض\". عطّلها للحصول على شبكة أنظف. شارات التقييم منفصلة، في الأسفل.",
"The next three screens need typing. Scan this and your phone does it for you.": "الشاشات الثلاث التالية تتطلب كتابة. امسح هذا ليقوم هاتفك بذلك عنك.",
"The number is bytes divided by the time they actually took to arrive. Cloudflare is a single origin, so on a very fast line this can read lower than a multi-server test like speedtest.net.": "الرقم هو عدد البايتات مقسومًا على الوقت الفعلي لوصولها. يستخدم Cloudflare مصدرًا واحدًا، لذا قد تكون النتيجة على اتصال سريع جدًا أقل من اختبار متعدد الخوادم مثل speedtest.net.",
"The one you are about to watch stays readable.": "ما أنت على وشك مشاهدته يبقى مقروءًا.",
Expand Down Expand Up @@ -9553,6 +9553,9 @@
"Top Movies on MAL": "أفضل الأفلام على MAL",
"Top Mystery": "أفضل مسلسلات الغموض",
"Top of card": "أعلى البطاقة",
"Award tabs": "علامات تبويب الجوائز",
"Award badge": "شارة الجوائز",
"No award": "بدون جوائز",
"Top of the screen": "أعلى الشاشة",
"Top on IMDb": "الأفضل على IMDb",
"Top on TMDB": "الأفضل على TMDB",
Expand Down
1 change: 1 addition & 0 deletions src/components/award-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export function AwardTab({ label, className = "" }: { label: string; className?:
if (!label) return null;
return (
<span
dir="ltr"
className={`inline-flex w-max items-center justify-center gap-1 rounded-[5px] border border-[#b8934a]/85 bg-[#1c1d1d] px-1.5 py-[3px] shadow-[0_2px_9px_rgba(0,0,0,0.55)] ${className}`}
>
<img
Expand Down
115 changes: 109 additions & 6 deletions src/components/card-award.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import { AwardTab } from "./award-tab";
import { AwardLogo } from "./icons/award-logo";
import { awardSummary, useAwards } from "@/lib/providers/wikidata";
import { mergeBundledAwards } from "@/lib/awards-history";
import { parseAwardYear } from "@/lib/anime-awards";
import { awardSourceMeta, findAnyAwardWins, findTopAward, parseAwardYear } from "@/lib/anime-awards";
import type { AwardWin } from "@/lib/anime-awards";
import type { Meta } from "@/lib/cinemeta";
import { shortCategory } from "@/lib/anime-award-labels";
import { useAwardIcon } from "@/lib/award-icons";
import { useT } from "@/lib/i18n";
import { stackTop, type CardLayout } from "./card-status-badges";

const NOUN: Record<string, string> = {
oscar: "Oscar",
Expand Down Expand Up @@ -49,16 +54,18 @@ export function ClassicAwardTab({
win,
below,
top,
topClass: topClassOverride,
}: {
win: ClassicWin | null;
below?: boolean;
top?: boolean;
topClass?: string;
}) {
if (!win) return null;
return (
<span
className={`pointer-events-none absolute left-1/2 z-10 -translate-x-1/2 ${
top ? "top-1.5" : below ? "bottom-1.5" : "bottom-7"
topClassOverride ?? (top ? "top-1.5" : below ? "bottom-1.5" : "bottom-7")
}`}
>
<AwardTab label={labelFor(win)} />
Expand All @@ -68,22 +75,118 @@ export function ClassicAwardTab({

export function ClassicAwardBadge({
win,
dubShift = false,
stacked = false,
dubShift = false,
ribbonShift = false,
layout = "poster",
topClass: topClassOverride,
side = "start",
}: {
win: ClassicWin | null;
dubShift?: boolean;
stacked?: boolean;
dubShift?: boolean;
ribbonShift?: boolean;
layout?: CardLayout;
topClass?: string;
side?: "start" | "end";
}) {
if (!win) return null;
const above = (dubShift ? 1 : 0) + (stacked ? 1 : 0);
const topClass = above >= 2 ? "top-[56px]" : above === 1 ? "top-[34px]" : "top-2";
const topClass = topClassOverride ?? stackTop(above, ribbonShift, layout);
const horizontalClass = side === "end" ? "end-2" : "start-2";
return (
<span
className={`pointer-events-none absolute start-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-md bg-canvas/95 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-[0.1em] text-ink ring-1 ring-edge-soft/60 ${topClass}`}
className={`pointer-events-none absolute ${horizontalClass} inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-md bg-canvas/95 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-[0.1em] text-ink ring-1 ring-edge-soft/60 ${topClass}`}
>
<AwardLogo type={win.type} size={11} />
<span className="truncate">{labelFor(win)}</span>
</span>
);
}

const AWARD_TAB_LABEL: Record<string, string> = {
crunchyroll: "Crunchyroll",
taaf: "TAAF",
jmaf: "JMAF",
r_anime: "r/anime",
animation_kobe: "Kobe",
};

export function AnimeAwardTab({
name,
fallbackName,
year,
below = false,
top = false,
topClass: topClassOverride,
}: {
name: string;
fallbackName?: string;
year?: number;
below?: boolean;
top?: boolean;
topClass?: string;
}) {
const primary = findTopAward(name, year);
const win = primary ?? (fallbackName ? findTopAward(fallbackName, year) : null);
if (!win) return null;
const lookupName = primary ? name : (fallbackName ?? name);
let label: string;
if (win.source === "crunchyroll") {
const count =
findAnyAwardWins(lookupName, year).filter((w) => w.source === "crunchyroll").length || 1;
label = `${count} ${count === 1 ? "Award" : "Awards"}`;
} else {
label = AWARD_TAB_LABEL[win.source] ?? awardSourceMeta(win.source).shortName;
}
return (
<span
className={`pointer-events-none absolute left-1/2 z-10 -translate-x-1/2 ${
topClassOverride ?? (top ? "top-1.5" : below ? "bottom-1.5" : "bottom-7")
}`}
>
<AwardTab label={label} />
</span>
);
}

export function AnimeAwardBadge({
win,
stacked,
dubShift = false,
ribbonShift = false,
layout = "poster",
side = "start",
}: {
win: AwardWin;
stacked: boolean;
dubShift?: boolean;
ribbonShift?: boolean;
layout?: CardLayout;
side?: "start" | "end";
}) {
const t = useT();
const src = awardSourceMeta(win.source);
const custom = useAwardIcon(win.source);
const short = shortCategory(win);
const above = (dubShift ? 1 : 0) + (stacked ? 1 : 0);
const label = above > 0 ? `${win.year}` : `${win.year} ${t(short)}`;
const topClass = stackTop(above, ribbonShift, layout);
const horizontalClass = side === "end" ? "end-2" : "start-2";
return (
<span
className={`pointer-events-none absolute ${horizontalClass} inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-md bg-canvas/95 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-[0.14em] text-ink ring-1 ring-edge-soft/60 ${topClass}`}
title={`${src.name} · ${win.categoryName} (${win.year})`}
>
<img
src={custom ?? src.iconSmall}
alt=""
width={11}
height={11}
className={`h-2.5 w-2.5 shrink-0 object-contain ${!custom && win.source === "animation_kobe" ? "brightness-0 invert" : ""}`}
draggable={false}
/>
<span className="truncate">{label}</span>
</span>
);
}
9 changes: 7 additions & 2 deletions src/components/card-score-stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,20 +95,25 @@ export function ScoreStack({
limit = 3,
placement = "bottom",
raised = false,
shiftedFromEnd = false,
topClass,
}: {
badges: readonly CardBadge[];
limit?: number;
placement?: "top" | "bottom";
raised?: boolean;
shiftedFromEnd?: boolean;
/** Optional top offset when TV-card overlays already occupy the top-right corner. */
topClass?: string;
}) {
const shown = badges.slice(0, Math.max(1, limit));
if (shown.length === 0) return null;
const scale = shown.length <= 3 ? 1 : shown.length === 4 ? 0.88 : shown.length === 5 ? 0.78 : 0.7;
return (
<div
style={scale < 1 ? { transform: `scale(${scale})`, transformOrigin: "right" } : undefined}
className={`absolute end-1.5 flex items-center gap-1 whitespace-nowrap rounded-md bg-canvas/95 px-1.5 py-0.5 text-[10px] font-semibold text-ink ${
placement === "top" ? "top-1.5" : raised ? "bottom-9" : "bottom-1.5"
className={`absolute ${shiftedFromEnd ? "end-8" : "end-1.5"} flex items-center gap-1 whitespace-nowrap rounded-md bg-canvas/95 px-1.5 py-0.5 text-[10px] font-semibold text-ink ${
placement === "top" ? (topClass ?? "top-1.5") : raised ? "bottom-9" : "bottom-1.5"
}`}
>
{shown.map((b, i) => (
Expand Down
125 changes: 125 additions & 0 deletions src/components/card-status-badges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { RefreshCcw } from "lucide-react";
import { useT } from "@/lib/i18n";
import { ClapperMini } from "@/components/icons/clapper-mini";
import type { BadgeTone } from "@/lib/card-badges";

export type CardLayout = "poster" | "tv";

const BADGE_STACKS: Record<CardLayout, { plain: string[]; ribbon: string[]; circles: string[] }> = {
poster: {
plain: ["top-2", "top-[34px]", "top-[54px]", "top-[74px]", "top-[94px]", "top-[114px]"],
ribbon: ["top-[34px]", "top-[54px]", "top-[74px]", "top-[94px]", "top-[114px]", "top-[134px]"],
circles: ["top-1.5", "top-[34px]", "top-[62px]", "top-[90px]", "top-[118px]", "top-[146px]"],
},
tv: {
plain: ["top-2", "top-[34px]", "top-[54px]", "top-[74px]", "top-[94px]", "top-[114px]"],
ribbon: ["top-[48px]", "top-[76px]", "top-[104px]", "top-[132px]", "top-[160px]", "top-[188px]"],
circles: ["top-1.5", "top-[48px]", "top-[76px]", "top-[104px]", "top-[132px]", "top-[152px]"],
},
};

export function stackTop(
slots: number,
ribbon: boolean,
layout: CardLayout = "poster",
circle = false,
): string {
const values = BADGE_STACKS[layout][circle ? "circles" : ribbon ? "ribbon" : "plain"];
return values[Math.min(Math.max(0, slots), values.length - 1)];
}

export function StatusBadge({
label,
tone = "default",
kids = false,
dubShift = false,
ribbonShift = false,
side = "start",
layout = "poster",
}: {
label: string;
tone?: BadgeTone;
kids?: boolean;
dubShift?: boolean;
ribbonShift?: boolean;
side?: "start" | "end";
layout?: CardLayout;
}) {
const styles = kids
? "bg-black text-white"
: tone === "accent"
? "bg-accent/90 text-canvas"
: "border border-edge-soft bg-canvas/95 text-ink";
return (
<span
className={`absolute ${side === "end" ? "end-2" : "start-2"} ${stackTop(dubShift ? 1 : 0, ribbonShift, layout)} rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] ${styles}`}
>
{label}
</span>
);
}

export function CinemaBadge({ dubShift = false, ribbonShift = false, side = "start", layout = "poster" }: { dubShift?: boolean; ribbonShift?: boolean; side?: "start" | "end"; layout?: CardLayout; className?: string }) {
const t = useT();
return (
<span
className={`harbor-cinema-badge absolute ${side === "end" ? "end-2" : "start-2"} ${stackTop(dubShift ? 1 : 0, ribbonShift, layout)} flex items-center gap-1 rounded-md bg-canvas/95 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-[0.14em]`}
>
<ClapperMini size={10} />
<span>{t("In Cinema")}</span>
</span>
);
}

export function RerunBadge({
year,
dubShift = false,
ribbonShift = false,
side = "start",
layout = "poster",
}: {
year?: string;
dubShift?: boolean;
ribbonShift?: boolean;
side?: "start" | "end";
layout?: CardLayout;
}) {
const t = useT();
return (
<span
className={`absolute ${side === "end" ? "end-2" : "start-2"} ${stackTop(dubShift ? 1 : 0, ribbonShift, layout)} flex items-center gap-1 rounded-md border border-edge-soft bg-canvas/95 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-[0.14em] text-ink-muted`}
>
<RefreshCcw size={9} strokeWidth={2.4} />
<span>
{t("Rerun")}
{year ? ` · ${year}` : ""}
</span>
</span>
);
}

export function KidsStarBadge({
value,
placement = "bottom",
}: {
value: string;
placement?: "top" | "bottom";
}) {
return (
<span
className={`pointer-events-none absolute end-1 grid h-9 w-9 place-items-center ${
placement === "top" ? "top-1" : "bottom-1"
}`}
>
<img
src="/kids/starbadge.svg"
alt=""
draggable={false}
className="absolute inset-0 h-full w-full object-contain drop-shadow-[0_2px_6px_rgba(0,0,0,0.3)]"
/>
<span className="relative translate-y-[1px] text-[9px] font-extrabold leading-none text-white [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]">
{value}
</span>
</span>
);
}
19 changes: 15 additions & 4 deletions src/components/curated-card-badge.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { cardBadge } from "@/lib/curated/registry";
import type { CuratedList, CuratedListItem } from "@/lib/curated/types";
import type { RowShape } from "./row";

export function CuratedCardBadge({ list, item }: { list: CuratedList; item: CuratedListItem }) {
export function CuratedCardBadge({
list,
item,
shape,
}: {
list: CuratedList;
item: CuratedListItem;
shape: RowShape;
}) {
const label = cardBadge(list, item);
if (!label) return null;
const badgeClass =
shape === "landscape"
? "absolute start-1.5 top-0.5 font-display text-[30px] font-semibold leading-none text-white/95 [text-shadow:0_1px_2px_rgba(0,0,0,0.9),_0_2px_8px_rgba(0,0,0,0.6)]"
: "absolute bottom-1.5 start-1.5 grid h-[19px] min-w-[19px] place-items-center rounded-md bg-canvas/95 px-1.5 text-[11px] font-semibold tabular-nums leading-none text-ink";
return (
<span className="pointer-events-none absolute inset-x-0 top-0 aspect-[2/3]">
<span className="absolute bottom-1.5 start-1.5 grid h-[19px] min-w-[19px] place-items-center rounded-md bg-canvas/95 px-1.5 text-[11px] font-semibold tabular-nums leading-none text-ink">
{label}
</span>
<span className={badgeClass}>{label}</span>
</span>
);
}
Loading
Loading