diff --git a/.gitignore b/.gitignore index 91cec8f..7497b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ out dist *.tgz static/css/tailwind.css +static/js/*.js # code coverage coverage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 459e49a..402729d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,7 @@ go run ./cmd/server ``` TypeScript source files live in `static/js/*.ts` and are bundled to matching `static/js/*.js` files for runtime. +Generated `static/js/*.js` and `static/css/tailwind.css` files are ignored by git. ## Development guidelines diff --git a/Dockerfile b/Dockerfile index 24041b6..5eb40b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,11 +8,22 @@ ENV CGO_ENABLED=1 # Install templ RUN go install github.com/a-h/templ/cmd/templ@latest +# Install bun for frontend asset builds +RUN apt-get update && apt-get install -y curl unzip && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:${PATH}" + COPY go.mod go.sum ./ RUN go mod download +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + COPY . . +# Build frontend assets (tailwind + ts) +RUN bun run build:assets + # Generate templ files RUN templ generate diff --git a/README.md b/README.md index 7ba036e..e8276c5 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ bun run build:assets go run ./cmd/server ``` -The frontend pipeline uses a single source stylesheet (`static/css/style.css`) as Tailwind v4 input, then emits `static/css/tailwind.css` for serving. +The frontend pipeline uses a single source stylesheet (`static/css/style.css`) and TypeScript sources in `static/js/*.ts`, then emits build artifacts (`static/css/tailwind.css` and `static/js/*.js`) for serving. When the server starts, the app is available at `http://localhost:3000`. diff --git a/static/js/anime.js b/static/js/anime.js deleted file mode 100644 index aba9a52..0000000 --- a/static/js/anime.js +++ /dev/null @@ -1,50 +0,0 @@ -// static/js/anime.ts -(() => { - const parseClassList = (value) => { - if (!value) { - return []; - } - return value.split(" ").map((entry) => entry.trim()).filter((entry) => entry.length > 0); - }; - const setMenuState = (menu, isOpen) => { - const openClasses = parseClassList(menu.getAttribute("data-dropdown-open-classes")); - const closedClasses = parseClassList(menu.getAttribute("data-dropdown-closed-classes")); - if (isOpen) { - menu.classList.remove(...closedClasses); - menu.classList.add(...openClasses); - return; - } - menu.classList.remove(...openClasses); - menu.classList.add(...closedClasses); - }; - const toggleDropdown = () => { - const dropdown = document.getElementById("watchlist-dropdown"); - if (!dropdown) { - return; - } - const isOpen = !dropdown.classList.contains("open"); - dropdown.classList.toggle("open", isOpen); - const menu = dropdown.querySelector("[data-dropdown-menu]"); - if (menu instanceof HTMLElement) { - setMenuState(menu, isOpen); - } - }; - window.toggleDropdown = toggleDropdown; - document.addEventListener("click", (event) => { - const dropdown = document.getElementById("watchlist-dropdown"); - if (!dropdown) { - return; - } - const target = event.target; - if (!(target instanceof Node)) { - return; - } - if (!dropdown.contains(target)) { - dropdown.classList.remove("open"); - const menu = dropdown.querySelector("[data-dropdown-menu]"); - if (menu instanceof HTMLElement) { - setMenuState(menu, false); - } - } - }); -})(); diff --git a/static/js/auth.js b/static/js/auth.js deleted file mode 100644 index 015f152..0000000 --- a/static/js/auth.js +++ /dev/null @@ -1,19 +0,0 @@ -// static/js/auth.ts -function copyRecoveryKey(keyElementId, feedbackElementId) { - const keyElement = document.getElementById(keyElementId); - const feedbackElement = document.getElementById(feedbackElementId); - if (!keyElement || !feedbackElement) { - return; - } - const key = keyElement.textContent || ""; - navigator.clipboard.writeText(key).then(() => { - feedbackElement.textContent = "Recovery key copied."; - }).catch(() => { - feedbackElement.textContent = "Copy failed. Select and copy manually."; - }); -} -function confirmDangerAction(message) { - return window.confirm(message); -} -window.copyRecoveryKey = copyRecoveryKey; -window.confirmDangerAction = confirmDangerAction; diff --git a/static/js/discover.js b/static/js/discover.js deleted file mode 100644 index 1751bef..0000000 --- a/static/js/discover.js +++ /dev/null @@ -1,37 +0,0 @@ -// static/js/discover.ts -(() => { - const parseClassList = (value) => { - if (!value) { - return []; - } - return value.split(" ").map((entry) => entry.trim()).filter((entry) => entry.length > 0); - }; - const setActiveTab = (clickedTab) => { - const group = clickedTab.closest('[data-tab-group="discover"]'); - if (!group) { - return; - } - const triggers = group.querySelectorAll("[data-tab-trigger]"); - triggers.forEach((tab) => { - const activeClasses2 = parseClassList(tab.getAttribute("data-tab-active-classes")); - const inactiveClasses2 = parseClassList(tab.getAttribute("data-tab-inactive-classes")); - tab.classList.remove(...activeClasses2); - tab.classList.add(...inactiveClasses2); - }); - const activeClasses = parseClassList(clickedTab.getAttribute("data-tab-active-classes")); - const inactiveClasses = parseClassList(clickedTab.getAttribute("data-tab-inactive-classes")); - clickedTab.classList.remove(...inactiveClasses); - clickedTab.classList.add(...activeClasses); - }; - document.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof Element)) { - return; - } - const trigger = target.closest("[data-tab-trigger]"); - if (!trigger) { - return; - } - setActiveTab(trigger); - }); -})(); diff --git a/static/js/search.js b/static/js/search.js deleted file mode 100644 index 9b5e3f1..0000000 --- a/static/js/search.js +++ /dev/null @@ -1,104 +0,0 @@ -// static/js/search.ts -(() => { - const globalWindow = window; - if (globalWindow.searchInitialized) { - return; - } - globalWindow.searchInitialized = true; - let searchTimeout; - const searchInput = document.getElementById("search-input"); - const searchDropdown = document.querySelector("[data-search-results-container]"); - if (!searchInput || !searchDropdown) { - return; - } - searchInput.addEventListener("input", (event) => { - if (searchTimeout) { - window.clearTimeout(searchTimeout); - } - const target = event.target; - if (!(target instanceof HTMLInputElement)) { - return; - } - const query = target.value.trim(); - if (query.length < 2) { - searchDropdown.replaceChildren(); - return; - } - searchTimeout = window.setTimeout(() => { - fetch("/api/search-quick?q=" + encodeURIComponent(query)).then((res) => res.json()).then((results) => { - if (!results || results.length === 0) { - searchDropdown.replaceChildren(); - return; - } - const searchResults = document.createElement("div"); - searchResults.className = "grid"; - const title = document.createElement("div"); - title.className = "px-3 py-2 text-[0.68rem] text-[var(--text-faint)]"; - title.textContent = "Anime"; - searchResults.appendChild(title); - results.forEach((result) => { - const item = document.createElement("a"); - item.className = "flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-[var(--panel-soft)] hover:no-underline"; - item.setAttribute("href", "/anime/" + encodeURIComponent(String(result.id || ""))); - if (isSafeImageUrl(result.image)) { - const img = document.createElement("img"); - img.className = "aspect-[2/3] w-[42px] shrink-0 object-cover bg-[var(--surface-thumb)]"; - img.setAttribute("src", result.image || ""); - img.setAttribute("alt", String(result.title || "")); - item.appendChild(img); - } else { - const noImage = document.createElement("div"); - noImage.className = "aspect-[2/3] w-[42px] shrink-0 bg-[var(--surface-thumb)] text-[0] text-transparent"; - noImage.textContent = "no image"; - item.appendChild(noImage); - } - const info = document.createElement("div"); - info.className = "grid min-w-0 gap-px"; - const itemTitle = document.createElement("div"); - itemTitle.className = "line-clamp-1 text-[0.86rem] leading-[1.3] text-[var(--text)]"; - itemTitle.textContent = String(result.title || ""); - info.appendChild(itemTitle); - const itemType = document.createElement("div"); - itemType.className = "text-[0.67rem] text-[var(--text-faint)]"; - itemType.textContent = String(result.type || ""); - info.appendChild(itemType); - item.appendChild(info); - searchResults.appendChild(item); - }); - const viewAll = document.createElement("a"); - viewAll.className = "bg-[var(--surface-search-view-all)] px-3 py-2 text-center text-[0.8rem] text-[var(--text-muted)] no-underline hover:bg-[var(--panel-soft)] hover:text-[var(--text)] hover:no-underline"; - viewAll.setAttribute("href", "/search?q=" + encodeURIComponent(query)); - viewAll.textContent = "View all results for " + query; - searchResults.appendChild(viewAll); - searchDropdown.replaceChildren(searchResults); - }).catch((err) => { - console.error("Search error:", err); - }); - }, 300); - }); - searchInput.addEventListener("blur", () => { - window.setTimeout(() => { - searchDropdown.replaceChildren(); - }, 200); - }); - document.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof Element)) { - return; - } - if (!target.closest("[data-search-root]")) { - searchDropdown.replaceChildren(); - } - }); - function isSafeImageUrl(rawUrl) { - if (!rawUrl || typeof rawUrl !== "string") { - return false; - } - try { - const parsed = new URL(rawUrl, window.location.origin); - return parsed.protocol === "https:" || parsed.protocol === "http:"; - } catch { - return false; - } - } -})(); diff --git a/static/js/timezone.js b/static/js/timezone.js deleted file mode 100644 index 0f7e887..0000000 --- a/static/js/timezone.js +++ /dev/null @@ -1,195 +0,0 @@ -// static/js/timezone.ts -(() => { - const jstOffsetMinutes = 9 * 60; - const parseBroadcastTime = (value) => { - if (!value || typeof value !== "string") { - return null; - } - const match = value.trim().match(/^(\d{1,2}):(\d{2})$/); - if (!match) { - return null; - } - const hour = Number.parseInt(match[1], 10); - const minute = Number.parseInt(match[2], 10); - if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { - return null; - } - return { hour, minute }; - }; - const isJstTimezone = (timezone) => { - if (!timezone) { - return true; - } - const normalized = timezone.trim().toLowerCase(); - return normalized === "asia/tokyo" || normalized === "jst"; - }; - const parseFromStructuredAttrs = (node) => { - const day = node.getAttribute("data-broadcast-day"); - const time = node.getAttribute("data-broadcast-time"); - const timezone = node.getAttribute("data-broadcast-timezone"); - if (!day || !time || !isJstTimezone(timezone)) { - return null; - } - const parsedTime = parseBroadcastTime(time); - if (!parsedTime) { - return null; - } - return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute }; - }; - const parseBroadcast = (text) => { - if (!text || typeof text !== "string") { - return null; - } - const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i); - if (!match) { - return null; - } - const day = match[1].trim(); - const hour = Number.parseInt(match[2], 10); - const minute = Number.parseInt(match[3], 10); - if (Number.isNaN(hour) || Number.isNaN(minute)) { - return null; - } - if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { - return null; - } - return { day, hour, minute }; - }; - const normalizeDay = (day) => { - const key = day.trim().toLowerCase().replace(/s$/, ""); - const days = { - mon: 1, - monday: 1, - tue: 2, - tues: 2, - tuesday: 2, - wed: 3, - wednesday: 3, - thu: 4, - thur: 4, - thurs: 4, - thursday: 4, - fri: 5, - friday: 5, - sat: 6, - saturday: 6, - sun: 0, - sunday: 0 - }; - if (typeof days[key] !== "number") { - return null; - } - return days[key]; - }; - const convertToLocal = (parsed, localOffsetMinutes) => { - const sourceMinutes = parsed.hour * 60 + parsed.minute; - const diff = jstOffsetMinutes - localOffsetMinutes; - const localTotal = sourceMinutes - diff; - const dayShift = Math.floor(localTotal / 1440); - const normalizedMinutes = (localTotal % 1440 + 1440) % 1440; - const localHour = Math.floor(normalizedMinutes / 60); - const localMinute = normalizedMinutes % 60; - const sourceDayIndex = normalizeDay(parsed.day); - if (sourceDayIndex === null) { - return null; - } - const localDayIndex = ((sourceDayIndex + dayShift) % 7 + 7) % 7; - const localDay = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][localDayIndex]; - const time = `${localHour.toString().padStart(2, "0")}:${localMinute.toString().padStart(2, "0")}`; - return `${localDay} at ${time} (Local)`; - }; - const nextAiringUTC = (parsed) => { - const targetDay = normalizeDay(parsed.day); - if (targetDay === null) { - return null; - } - const now = new Date; - const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000); - const currentDay = jstNow.getUTCDay(); - const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes(); - const targetMinuteOfDay = parsed.hour * 60 + parsed.minute; - let dayDelta = (targetDay - currentDay + 7) % 7; - if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) { - dayDelta = 7; - } - const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay); - return new Date(now.getTime() + minuteDelta * 60 * 1000); - }; - const formatRelative = (value, unit) => { - if (typeof Intl !== "undefined" && typeof Intl.RelativeTimeFormat === "function") { - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); - return formatter.format(value, unit); - } - const suffix = value === 1 ? unit : `${unit}s`; - return `in ${value} ${suffix}`; - }; - const relativeText = (target) => { - const diffMs = target.getTime() - Date.now(); - if (diffMs <= 0) { - return "soon"; - } - const minutes = Math.ceil(diffMs / 60000); - if (minutes < 60) { - return formatRelative(minutes, "minute"); - } - const hours = Math.ceil(minutes / 60); - if (hours < 36) { - return formatRelative(hours, "hour"); - } - const days = Math.ceil(hours / 24); - return formatRelative(days, "day"); - }; - const localDateTimeText = (date) => { - const formatter = new Intl.DateTimeFormat(undefined, { - weekday: "short", - hour: "2-digit", - minute: "2-digit" - }); - return formatter.format(date); - }; - const updateNextAiring = (node, parsed) => { - const card = node.closest("[data-notification-content]"); - if (!card) { - return; - } - const nextNode = card.querySelector("[data-next-airing]"); - if (!(nextNode instanceof HTMLElement)) { - return; - } - const nextDate = nextAiringUTC(parsed); - if (!nextDate) { - nextNode.remove(); - return; - } - nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`; - }; - const updateNode = (node, localOffsetMinutes) => { - const card = node.closest("[data-notification-content]"); - const nextNode = card ? card.querySelector("[data-next-airing]") : null; - const structured = parseFromStructuredAttrs(node); - const source = node.getAttribute("data-jst-text"); - const parsed = structured || parseBroadcast(source); - if (!parsed) { - if (nextNode instanceof HTMLElement) { - nextNode.remove(); - } - return; - } - const converted = convertToLocal(parsed, localOffsetMinutes); - if (!converted) { - if (nextNode instanceof HTMLElement) { - nextNode.remove(); - } - return; - } - node.textContent = converted; - updateNextAiring(node, parsed); - }; - const updateAll = () => { - const localOffsetMinutes = -new Date().getTimezoneOffset(); - const nodes = document.querySelectorAll("[data-jst-text]"); - nodes.forEach((node) => updateNode(node, localOffsetMinutes)); - }; - document.addEventListener("DOMContentLoaded", updateAll); - document.body.addEventListener("htmx:afterSwap", updateAll); -})();