A personal media tracker built on the AT Protocol opnshelf.xyz
at main 191 lines 4.9 kB view raw
1import { type ClassValue, clsx } from "clsx"; 2import { twMerge } from "tailwind-merge"; 3 4export function cn(...inputs: ClassValue[]) { 5 return twMerge(clsx(inputs)); 6} 7 8export function createTitleSlug(title: string): string { 9 return title 10 .replace(/[^a-zA-Z0-9\s-]/g, "") 11 .trim() 12 .replace(/\s+/g, "-"); 13} 14 15export function formatRuntime(minutes: number, useHours: boolean): string { 16 if (!useHours) return `${minutes} min`; 17 const hours = Math.floor(minutes / 60); 18 const mins = minutes % 60; 19 if (mins === 0) return `${hours} hours`; 20 return `${hours} hours ${mins} minutes`; 21} 22 23export function getTmdbPosterUrl( 24 path: string | null | undefined, 25 size: "w342" | "w500" | "w780" = "w342", 26): string | null { 27 if (!path) return null; 28 return `https://image.tmdb.org/t/p/${size}${path}`; 29} 30 31export function getTmdbBackdropUrl( 32 path: string | null | undefined, 33): string | null { 34 if (!path) return null; 35 return `https://image.tmdb.org/t/p/w1280${path}`; 36} 37 38export function getTmdbProfileUrl( 39 path: string | null | undefined, 40): string | null { 41 if (!path) return null; 42 return `https://image.tmdb.org/t/p/w185${path}`; 43} 44 45export function buildScopedShowMediaId( 46 showId: string, 47 seasonNumber?: number, 48 episodeNumber?: number, 49): string { 50 if (typeof seasonNumber === "number" && Number.isFinite(seasonNumber)) { 51 if (typeof episodeNumber === "number" && Number.isFinite(episodeNumber)) { 52 return `${showId}:season:${seasonNumber}:episode:${episodeNumber}`; 53 } 54 return `${showId}:season:${seasonNumber}`; 55 } 56 return showId; 57} 58 59export function parseScopedShowMediaId(mediaId: string): { 60 showId: string; 61 seasonNumber?: number; 62 episodeNumber?: number; 63} { 64 const episodeMatch = mediaId.match(/^([^:]+):season:(\d+):episode:(\d+)$/); 65 if (episodeMatch) { 66 return { 67 showId: episodeMatch[1], 68 seasonNumber: Number(episodeMatch[2]), 69 episodeNumber: Number(episodeMatch[3]), 70 }; 71 } 72 73 const seasonMatch = mediaId.match(/^([^:]+):season:(\d+)$/); 74 if (seasonMatch) { 75 return { 76 showId: seasonMatch[1], 77 seasonNumber: Number(seasonMatch[2]), 78 }; 79 } 80 81 return { showId: mediaId }; 82} 83 84export interface DateFormatOptions { 85 timezone: string; 86 is24Hour: boolean; 87 includeTime?: boolean; 88} 89 90export function getDayKeyInTimezone( 91 dateString: string | Date, 92 timezone: string, 93): string { 94 const date = 95 typeof dateString === "string" ? new Date(dateString) : dateString; 96 97 try { 98 const formatter = new Intl.DateTimeFormat("en-US", { 99 timeZone: timezone, 100 year: "numeric", 101 month: "2-digit", 102 day: "2-digit", 103 }); 104 const parts = formatter.formatToParts(date); 105 const year = parts.find((part) => part.type === "year")?.value ?? "0000"; 106 const month = parts.find((part) => part.type === "month")?.value ?? "01"; 107 const day = parts.find((part) => part.type === "day")?.value ?? "01"; 108 return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; 109 } catch { 110 return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; 111 } 112} 113 114export function getShelfDayLabel(dayKey: string, timezone: string): string { 115 const now = new Date(); 116 const todayKey = getDayKeyInTimezone(now, timezone); 117 const yesterdayKey = getDayKeyInTimezone( 118 new Date(now.getTime() - 24 * 60 * 60 * 1000), 119 timezone, 120 ); 121 122 if (dayKey === todayKey) return "Today"; 123 if (dayKey === yesterdayKey) return "Yesterday"; 124 125 const [year, month, day] = dayKey.split("-").map(Number); 126 const safeDate = new Date(Date.UTC(year, month - 1, day, 12, 0, 0)); 127 const currentYear = Number(todayKey.split("-")[0] ?? now.getUTCFullYear()); 128 129 return safeDate.toLocaleDateString("en-US", { 130 weekday: "long", 131 month: "long", 132 day: "numeric", 133 ...(year !== currentYear ? { year: "numeric" } : {}), 134 }); 135} 136 137export function formatDateWithTimezone( 138 dateString: string | Date, 139 options: DateFormatOptions, 140): string { 141 const { timezone, is24Hour, includeTime = true } = options; 142 const date = 143 typeof dateString === "string" ? new Date(dateString) : dateString; 144 145 try { 146 return date.toLocaleString("en-US", { 147 year: "numeric", 148 month: "short", 149 day: "numeric", 150 ...(includeTime && { 151 hour: "2-digit", 152 minute: "2-digit", 153 hour12: !is24Hour, 154 }), 155 timeZone: timezone, 156 }); 157 } catch { 158 return date.toLocaleString("en-US", { 159 year: "numeric", 160 month: "short", 161 day: "numeric", 162 ...(includeTime && { 163 hour: "2-digit", 164 minute: "2-digit", 165 hour12: !is24Hour, 166 }), 167 }); 168 } 169} 170 171export function formatDateOnly( 172 dateString: string | Date, 173 timezone = "UTC", 174): string { 175 const date = 176 typeof dateString === "string" ? new Date(dateString) : dateString; 177 try { 178 return date.toLocaleDateString("en-US", { 179 year: "numeric", 180 month: "long", 181 day: "numeric", 182 timeZone: timezone, 183 }); 184 } catch { 185 return date.toLocaleDateString("en-US", { 186 year: "numeric", 187 month: "long", 188 day: "numeric", 189 }); 190 } 191}