build: ignore generated js artifacts
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ out
|
||||
dist
|
||||
*.tgz
|
||||
static/css/tailwind.css
|
||||
static/js/*.js
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
11
Dockerfile
11
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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user