diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21a30a8..459e49a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,8 @@ go test ./... go run ./cmd/server ``` +TypeScript source files live in `static/js/*.ts` and are bundled to matching `static/js/*.js` files for runtime. + ## Development guidelines - Follow existing folder boundaries (`internal/features/*`, `internal/jikan`, `internal/templates`) diff --git a/README.md b/README.md index 28c866a..7ba036e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ 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. + When the server starts, the app is available at `http://localhost:3000`. For containerized usage, the included `Dockerfile` uses a multi-stage build that generates templates, compiles `cmd/server`, and ships a slim runtime image with SQLite support. diff --git a/package.json b/package.json index b813482..deb43c9 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,9 @@ "name": "myanimelist-ui", "private": true, "scripts": { - "build:css": "bunx @tailwindcss/cli -i ./static/css/tailwind.input.css -o ./static/css/tailwind.css", - "watch:css": "bunx @tailwindcss/cli -i ./static/css/tailwind.input.css -o ./static/css/tailwind.css --watch", - "build:ts": "bunx tsc -p tsconfig.json", + "build:css": "bunx @tailwindcss/cli -i ./static/css/style.css -o ./static/css/tailwind.css", + "watch:css": "bunx @tailwindcss/cli -i ./static/css/style.css -o ./static/css/tailwind.css --watch", + "build:ts": "bun build ./static/js/*.ts --outdir ./static/js --target browser", "typecheck": "bunx tsc -p tsconfig.json --noEmit", "build:assets": "bun run build:css && bun run build:ts" }, diff --git a/static/css/style.css b/static/css/style.css index 0151dcf..8307474 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,3 +1,7 @@ +@import 'tailwindcss'; + +@source '../../internal/**/*.templ'; + :root { --bg: #111419; --panel: #181d24; diff --git a/static/css/tailwind.css b/static/css/tailwind.css index a1a7c3f..b866d70 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -15,10 +15,6 @@ --font-weight-semibold: 600; --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); - --color-panel: #181d24; - --color-text-muted: #b8c0cd; - --color-text-faint: #8b97a8; - --color-accent: #cad4e4; } } @layer base { @@ -382,6 +378,1070 @@ } } } +:root { + --bg: #111419; + --panel: #181d24; + --panel-soft: #1f2530; + --header: #1a2029; + --text: #e7eaf0; + --text-muted: #b8c0cd; + --text-faint: #8b97a8; + --accent: #cad4e4; + --danger: #d17f88; + --surface-search: rgba(10, 13, 18, 0.3); + --surface-search-focus-border: rgba(202, 212, 228, 0.24); + --surface-thumb: #141920; + --surface-input: #151b23; + --surface-input-focus: #1c2531; + --surface-tab-hover: #2a3340; + --surface-tab-active: #323d4c; + --surface-select: #1a212b; + --surface-search-view-all: #151b23; + --text-on-accent: #111419; + --overlay-subtle: rgba(0, 0, 0, 0.45); + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --poster-max-height: 360px; + --font: 'Verdana', 'Tahoma', 'Segoe UI', sans-serif; +} +* { + box-sizing: border-box; +} +html, body { + margin: 0; + padding: 0; +} +body { + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; + line-height: 1.45; +} +a { + color: var(--text); + text-decoration: none; +} +a:hover { + color: var(--accent); + text-decoration: underline; +} +a:visited { + color: var(--text); +} +:focus-visible { + outline: 1px dotted var(--text-faint); + outline-offset: 2px; +} +:focus:not(:focus-visible) { + outline: none; +} +header { + position: sticky; + top: 0; + z-index: 100; + background: var(--header); +} +.header-top { + width: 100%; + max-width: 1580px; + margin: 0 auto; + padding: var(--space-3) var(--space-4); + display: flex; + align-items: center; + gap: var(--space-4); +} +.header-left { + display: flex; + align-items: center; + gap: var(--space-5); + min-width: 0; +} +.logo { + display: inline-flex; + align-items: center; + color: var(--accent); +} +.logo-svg { + width: 28px; + height: 28px; +} +.nav { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.nav a { + color: var(--text-muted); + font-size: 0.85rem; + text-decoration: none; +} +.nav a:hover { + color: var(--text); + text-decoration: none; +} +.header-search-wrapper { + margin-left: auto; + width: min(420px, 45vw); + min-width: 240px; + position: relative; +} +.header-search { + width: 100%; +} +.search-input { + width: 100%; + height: 34px; + border: 1px solid transparent; + background: var(--surface-search); + color: var(--text); + padding: 0 var(--space-3); + font: inherit; + transition: border-color 120ms ease, background-color 120ms ease; +} +.search-input::placeholder { + color: var(--text-faint); +} +.search-input:focus { + border-color: var(--surface-search-focus-border); +} +.search-input:focus-visible { + outline: none; +} +.search-dropdown { + position: absolute; + top: calc(100% + 2px); + left: 0; + right: 0; + max-height: min(70vh, 560px); + overflow-y: auto; + background: var(--panel); + z-index: 120; +} +.search-results { + display: grid; +} +.search-results-title { + padding: var(--space-2) var(--space-3); + color: var(--text-faint); + font-family: var(--font); + font-size: 0.68rem; +} +.search-result-item { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + color: inherit; + text-decoration: none; +} +.search-result-item:hover { + background: var(--panel-soft); + text-decoration: none; +} +.search-result-thumb, .search-result-no-image { + width: 42px; + aspect-ratio: 2 / 3; + flex-shrink: 0; +} +.search-result-thumb { + object-fit: cover; + background: var(--surface-thumb); +} +.search-result-no-image { + background: var(--surface-thumb); + font-size: 0; + color: transparent; +} +.search-result-info { + min-width: 0; + display: grid; + gap: 1px; +} +.search-result-title { + color: var(--text); + font-size: 0.86rem; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} +.search-result-type { + color: var(--text-faint); + font-family: var(--font); + font-size: 0.67rem; +} +.search-result-view-all { + padding: var(--space-2) var(--space-3); + color: var(--text-muted); + background: var(--surface-search-view-all); + text-align: center; + font-size: 0.8rem; + text-decoration: none; +} +.search-result-view-all:hover { + background: var(--panel-soft); + color: var(--text); + text-decoration: none; +} +main { + width: 100%; + max-width: 1580px; + margin: 0 auto; + padding: var(--space-5) var(--space-4) var(--space-8); +} +.auth-main { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 0 var(--space-4); +} +.is-hidden { + display: none !important; +} +.grid-full-width { + grid-column: 1 / -1; +} +.full-span-trigger { + grid-column: 1 / -1; + height: 1px; +} +.scroll-trigger { + width: 100%; +} +.catalog-grid, .notifications-list, .relations-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: var(--space-4) var(--space-4); +} +.catalog-item, .relation-card, .notification-card { + min-width: 0; +} +.catalog-placeholder { + pointer-events: none; +} +.catalog-placeholder-thumb { + width: 100%; + max-height: var(--poster-max-height); + aspect-ratio: 2 / 3; + background: linear-gradient(90deg, var(--surface-search) 0%, rgba(255, 255, 255, 0.08) 45%, var(--surface-search) 100%); + background-size: 220% 100%; + animation: placeholder-shimmer 1.4s linear infinite; +} +.catalog-placeholder-title { + margin-top: var(--space-2); + height: 0.9rem; + width: 80%; + background: linear-gradient(90deg, var(--surface-search) 0%, rgba(255, 255, 255, 0.08) 45%, var(--surface-search) 100%); + background-size: 220% 100%; + animation: placeholder-shimmer 1.4s linear infinite; +} +@keyframes placeholder-shimmer { + from { + background-position: 100% 0; + } + to { + background-position: -100% 0; + } +} +.catalog-item > a, .catalog-item a, .relation-card, .notification-card { + display: flex; + flex-direction: column; + color: inherit; + text-decoration: none; + background: transparent; +} +.no-image, .schedule-card-image, .notification-image { + width: 100%; + max-height: var(--poster-max-height); + aspect-ratio: 2 / 3; + display: flex; + align-items: flex-end; + justify-content: center; + overflow: hidden; +} +.catalog-thumb, .relation-thumb { + width: 100%; + max-height: var(--poster-max-height); + aspect-ratio: 2 / 3; + display: block; + object-fit: cover; + object-position: center; +} +.schedule-card-image img, .notification-image img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + object-position: center; +} +.no-image { + font-size: 0; + color: transparent; +} +.catalog-title, .relation-title, .notification-title { + margin-top: var(--space-2); + color: var(--text); + font-size: 0.86rem; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.relation-type { + margin-top: var(--space-1); + color: var(--text-faint); + font-size: 0.76rem; +} +.catalog-item:hover .catalog-title, .relation-card:hover .relation-title, .notification-card:hover .notification-title { + color: var(--accent); +} +.notification-content { + padding: 0; + margin-top: var(--space-2); + display: grid; + gap: var(--space-1); +} +.notification-meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.notification-meta span { + color: var(--text-faint); + font-family: var(--font); + font-size: 0.67rem; +} +.notification-progress, .notification-muted, .notification-broadcast { + color: var(--text-faint) !important; + font-family: var(--font); + font-size: 0.67rem; +} +.loading-indicator { + display: inline-flex; + align-items: center; + gap: var(--space-2); + color: var(--text-muted); + font-size: 0.9rem; +} +.loading-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--text-faint); +} +.htmx-indicator { + display: none; +} +.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { + display: inline-flex; +} +.empty-state, .no-anime { + padding: var(--space-4) 0; +} +.empty-state-title { + margin: 0 0 var(--space-2); + font-size: 1rem; +} +.empty-state-text, .empty-inline-note, .empty-state-hint { + color: var(--text-muted); + font-size: 0.9rem; +} +.not-found-page { + width: min(780px, calc(100vw - (var(--space-6) * 2))); + min-height: 72vh; + margin: 0 auto; + padding: var(--space-8) var(--space-7); + display: grid; + align-content: center; + justify-items: center; + gap: var(--space-3); + text-align: center; +} +.not-found-code { + margin: 0; + color: var(--text-muted); + font-size: clamp(4rem, 15vw, 10rem); + letter-spacing: 0.04em; + line-height: 0.9; +} +.not-found-page h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3rem); +} +.not-found-link { + color: var(--accent); + text-decoration: none; + font-size: 1.05rem; +} +.not-found-link:hover { + text-decoration: underline; +} +.auth-shell { + width: min(560px, 100%); +} +.login-container { + width: min(560px, 100%); + margin: 0 auto; + padding: var(--space-6); + background: var(--panel); +} +.login-container h2 { + margin: 0; + font-size: 1.4rem; +} +.login-subtitle { + margin: var(--space-3) 0 var(--space-5); + color: var(--text-muted); + font-size: 0.95rem; +} +.login-form { + display: grid; + gap: var(--space-4); +} +.form-group { + display: grid; + gap: var(--space-1); +} +.form-group label { + color: var(--text-muted); + font-size: 0.9rem; +} +.form-group input { + height: 40px; + border: 1px solid transparent; + background: var(--surface-search); + color: var(--text); + padding: 0 var(--space-3); + font: inherit; + transition: border-color 120ms ease, background-color 120ms ease; +} +.form-group input:focus { + border-color: var(--surface-search-focus-border); +} +.form-group input:focus-visible { + outline: none; +} +.auth-password-note { + margin: 0; + color: var(--text-faint); + font-size: 0.75rem; + line-height: 1.4; +} +.auth-form-error { + margin: var(--space-2) 0 0; + color: var(--danger); + font-size: 0.82rem; +} +.recovery-key-box { + margin: 0; + padding: var(--space-3); + background: var(--surface-search); + border: 1px solid var(--surface-search-focus-border); + color: var(--text); + word-break: break-all; + font-size: 0.86rem; +} +.recovery-key-row { + display: grid; + gap: var(--space-2); +} +.recovery-copy-btn { + justify-self: start; + min-width: 0; + padding: 0.42rem 0.72rem; + border: 1px solid var(--surface-search-focus-border); + background: var(--surface-search); + color: var(--text); + border-radius: 0; + font-size: 0.8rem; + line-height: 1; + cursor: pointer; +} +.recovery-copy-btn:hover { + background: var(--surface-input-focus); + color: var(--text); + text-decoration: none; +} +.recovery-copy-btn:focus-visible { + outline: none; + border-color: var(--accent); +} +.recovery-copy-feedback { + margin-top: var(--space-2); + min-height: 1.1rem; +} +.auth-primary-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 var(--space-4); + border: 1px solid var(--surface-search-focus-border); + background: var(--surface-search); + color: var(--text); + text-decoration: none; +} +.auth-primary-link:hover { + background: var(--panel-soft); + color: var(--text); + text-decoration: none; +} +.auth-primary-link:focus-visible { + outline: none; + border-color: var(--accent); +} +.account-page { + width: min(720px, 100%); + margin: 0 auto; + display: grid; + gap: var(--space-4); +} +.account-card { + background: var(--panel); + padding: var(--space-5); + display: grid; + gap: var(--space-3); +} +.account-card h2, .account-card h3 { + margin: 0; +} +.account-meta { + display: grid; + gap: var(--space-2); +} +.account-meta-row { + display: grid; + gap: var(--space-1); +} +.account-meta-label { + color: var(--text-faint); + font-size: 0.78rem; +} +.account-meta-value { + color: var(--text); + font-size: 0.95rem; +} +.account-form { + display: grid; + gap: var(--space-3); +} +.account-form .form-group input { + width: 100%; +} +.account-submit-btn, .account-logout-btn { + height: 40px; + border: 1px solid var(--surface-search-focus-border); + background: var(--surface-search); + color: var(--text); + padding: 0 var(--space-4); + cursor: pointer; +} +.account-submit-btn:hover, .account-logout-btn:hover { + background: var(--panel-soft); +} +.account-submit-btn:focus-visible, .account-logout-btn:focus-visible { + outline: none; + border-color: var(--accent); +} +.account-success { + margin: 0; + color: var(--accent); + font-size: 0.82rem; +} +.account-form-inline { + display: inline-flex; +} +.login-button { + height: 40px; + border: none; + background: var(--accent); + color: var(--text-on-accent); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; +} +.login-button:hover { + filter: brightness(0.95); +} +.auth-switch-row { + margin: var(--space-5) 0 0; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; +} +.auth-switch-row a { + color: var(--accent); +} +.watchlist-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: var(--space-4); + margin-bottom: var(--space-4); +} +.watchlist-heading { + display: grid; + gap: var(--space-1); +} +.watchlist-heading h2 { + margin: 0; + font-size: 1.2rem; +} +.watchlist-subtitle { + margin: 0; + color: var(--text-muted); + font-size: 0.86rem; +} +.watchlist-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: flex-end; +} +.text-link { + display: inline-flex; + align-items: center; + justify-content: center; + font: inherit; + appearance: none; + border: none; + background: none; + color: var(--text-muted); + padding: 0.24rem 0.45rem; + min-width: 64px; + text-align: center; + cursor: pointer; + font-size: 0.8rem; + line-height: 1.2; +} +.text-link:visited { + color: var(--text-muted); +} +.text-link:hover { + color: var(--accent); + text-decoration: none; +} +.view-toggle, .status-tabs, .tabs { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.status-tabs { + margin-bottom: var(--space-3); +} +.view-toggle a, .status-tabs a, .tab { + flex: 0 0 auto; + border: none; + background: var(--panel-soft); + color: var(--text-muted); + padding: 0.24rem 0.45rem; + font-size: 0.76rem; + font-family: var(--font); + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} +.view-toggle a:hover, .status-tabs a:hover, .tab:hover { + color: var(--text); + background: var(--surface-tab-hover); + text-decoration: none; +} +.view-toggle a.active, .status-tabs a.active, .tab.active { + color: var(--accent); + background: var(--surface-tab-active); +} +.watchlist-item { + position: relative; +} +.remove-btn { + position: absolute; + top: 8px; + right: 8px; + width: 22px; + height: 22px; + border: none; + background: var(--overlay-subtle); + color: var(--text-muted); + cursor: pointer; + opacity: 0; +} +.watchlist-item:hover .remove-btn { + opacity: 1; +} +.remove-btn:hover, .remove-link:hover, .dropdown-item.remove:hover { + color: var(--danger); +} +.watchlist-table { + width: 100%; + border-collapse: collapse; + background: var(--panel); +} +.watchlist-table th { + text-align: left; + color: var(--text-faint); + font-family: var(--font); + font-size: 0.67rem; + padding: 0.6rem; +} +.watchlist-table td { + padding: 0.6rem; +} +.watchlist-table tr:hover { + background: var(--panel-soft); +} +.thumb { + width: 36px; + aspect-ratio: 2 / 3; + object-fit: cover; +} +.title-cell { + font-weight: 500; +} +.actions-cell { + width: 90px; +} +.remove-link { + border: none; + background: none; + color: var(--text-muted); + padding: 0; + font-size: 0.8rem; + cursor: pointer; +} +.sort-filter { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); + margin-bottom: var(--space-4); + padding: var(--space-3); + background: var(--panel); +} +.sort-filter-group { + display: flex; + align-items: center; + gap: var(--space-2); +} +.sort-filter-group label, .density-label { + color: var(--text-muted); + font-size: 0.78rem; +} +.sort-filter-select { + height: 30px; + border: none; + background: var(--surface-select); + color: var(--text); + padding: 0 var(--space-2); + font-size: 0.78rem; + font-family: var(--font); +} +.anime-page { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: var(--space-5); + align-items: start; +} +.anime-main { + min-width: 0; + display: grid; + gap: var(--space-8); +} +.anime-surface { + border: none; + background: transparent; + padding: 0; +} +.anime-section { + margin: 0; +} +.anime-hero { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: var(--space-5); +} +.anime-poster { + width: 220px; +} +.anime-poster img, .anime-poster .no-image { + width: 100%; +} +.anime-info h1 { + margin: 0; + font-size: 1.45rem; + line-height: 1.2; +} +.anime-alt-title { + margin: var(--space-2) 0 var(--space-3); + color: var(--text-muted); + font-size: 0.9rem; +} +.anime-quick-info { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.info-tag { + color: var(--text-faint); + font-family: var(--font); + font-size: 0.67rem; +} +.anime-actions { + margin-top: var(--space-3); +} +.anime-synopsis { + margin-top: var(--space-4); + max-width: 100ch; +} +.anime-synopsis h3, .anime-relations h3, .anime-recommendations h3 { + margin: 0 0 var(--space-3); + color: var(--text); + font-family: var(--font); + font-size: 1.25rem; + font-weight: 600; + line-height: 1.2; +} +.anime-side-section h3 { + margin: 0 0 var(--space-2); + color: var(--text-faint); + font-family: var(--font); + font-size: 0.78rem; +} +.anime-synopsis p { + margin: 0; + color: var(--text-muted); +} +.no-synopsis { + color: var(--text-faint); +} +.anime-sidebar { + position: sticky; + top: 74px; + display: grid; + gap: var(--space-4); + background: var(--panel); + padding: var(--space-3); +} +.anime-side-section { + display: grid; + gap: var(--space-3); +} +.sidebar-row { + display: grid; + gap: var(--space-1); + margin-top: var(--space-1); +} +.sidebar-label { + color: var(--text-faint); + font-family: var(--font); + font-size: 0.84rem; + margin-top: 2px; +} +.sidebar-value { + color: var(--text-muted); + font-size: 0.84rem; +} +.sidebar-row-wrap { + gap: var(--space-1); +} +.sidebar-tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-top: 1px; +} +.sidebar-tag { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 0.84rem; + font-family: var(--font); + padding: 0; +} +.side-details-more { + border: none; + padding-top: 0; +} +.side-details-more summary { + color: var(--text-muted); + font-size: 0.82rem; + cursor: pointer; +} +.side-details-more[open] { + display: grid; + gap: var(--space-3); +} +.dropdown { + position: relative; + display: inline-block; +} +.dropdown-trigger { + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: 32px; + border: none; + background: var(--panel-soft); + color: var(--text); + padding: 0 var(--space-2); + font-size: 0.8rem; + font-family: var(--font); + cursor: pointer; +} +.dropdown-arrow { + font-size: 0.64rem; +} +.dropdown-menu { + position: absolute; + top: calc(100% + 2px); + left: 0; + min-width: 210px; + background: var(--panel); + display: none; + z-index: 110; +} +.dropdown.open .dropdown-menu { + display: block; +} +.dropdown-item { + width: 100%; + border: none; + background: transparent; + color: var(--text-muted); + padding: 0.5rem 0.62rem; + font-size: 0.78rem; + font-family: var(--font); + text-align: left; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +} +.dropdown-item:hover, .dropdown-item.active { + background: var(--panel-soft); + color: var(--text); +} +.dropdown-divider { + display: none; +} +.dropdown-item .check { + color: var(--accent); +} +.discover-container, .discover-header, .notifications-page { + display: grid; + gap: var(--space-4); +} +.discover-header h1, .notifications-page h1 { + margin: 0; + font-size: 1.2rem; +} +.discover-subtitle, .notifications-subtitle { + margin: 0; + color: var(--text-muted); + font-size: 0.88rem; +} +.notifications-section-title { + margin-top: var(--space-6); +} +.notifications-group-title { + margin: 0 0 var(--space-2); + color: var(--text); + font-size: 1.25rem; + font-weight: 600; + line-height: 1.2; +} +.notifications-group-note { + margin: 0 0 var(--space-4); + color: var(--text-muted); + font-size: 0.88rem; +} +.notifications-group { + display: grid; + gap: var(--space-3); +} +.notifications-list-spaced { + margin-bottom: var(--space-4); +} +@media (max-width: 1040px) { + .anime-page { + grid-template-columns: minmax(0, 1fr); + } + .anime-sidebar { + position: static; + } +} +@media (max-width: 860px) { + .header-top { + flex-wrap: wrap; + gap: var(--space-3); + } + .header-left { + width: 100%; + flex-wrap: wrap; + gap: var(--space-3); + } + .nav { + width: 100%; + gap: var(--space-2); + } + .header-search-wrapper { + width: 100%; + margin-left: 0; + } + main { + padding: var(--space-5) var(--space-3) var(--space-6); + } + .watchlist-header { + flex-direction: column; + align-items: flex-start; + } + .watchlist-controls { + width: 100%; + justify-content: flex-start; + } + .sort-filter { + flex-direction: column; + align-items: flex-start; + gap: var(--space-2); + } + .anime-hero { + grid-template-columns: minmax(0, 1fr); + } + .anime-poster { + width: min(230px, 58vw); + } +} +@media (max-width: 680px) { + .catalog-grid, .notifications-list, .relations-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + } + .status-tabs, .tabs, .view-toggle { + flex-wrap: nowrap; + overflow-x: auto; + gap: var(--space-2); + padding-bottom: var(--space-1); + -webkit-overflow-scrolling: touch; + } + .status-tabs a, .tabs .tab, .view-toggle a { + padding: 0.34rem 0.62rem; + } + .watchlist-table { + display: block; + overflow-x: auto; + white-space: nowrap; + } +} @property --tw-border-style { syntax: "*"; inherits: false; diff --git a/static/css/tailwind.input.css b/static/css/tailwind.input.css deleted file mode 100644 index fda0d34..0000000 --- a/static/css/tailwind.input.css +++ /dev/null @@ -1,15 +0,0 @@ -@import 'tailwindcss'; - -@source '../../internal/**/*.templ'; - -@theme { - --color-bg: #111419; - --color-panel: #181d24; - --color-panel-soft: #1f2530; - --color-header: #1a2029; - --color-text: #e7eaf0; - --color-text-muted: #b8c0cd; - --color-text-faint: #8b97a8; - --color-accent: #cad4e4; - --color-danger: #d17f88; -} diff --git a/static/js/anime.js b/static/js/anime.js index d719dbf..3c7410e 100644 --- a/static/js/anime.js +++ b/static/js/anime.js @@ -1,24 +1,24 @@ -"use strict"; +// static/js/anime.ts (() => { - const toggleDropdown = () => { - const dropdown = document.getElementById('watchlist-dropdown'); - if (!dropdown) { - return; - } - dropdown.classList.toggle('open'); - }; - 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 toggleDropdown = () => { + const dropdown = document.getElementById("watchlist-dropdown"); + if (!dropdown) { + return; + } + dropdown.classList.toggle("open"); + }; + 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"); + } + }); })(); diff --git a/static/js/anime.ts b/static/js/anime.ts new file mode 100644 index 0000000..d823c78 --- /dev/null +++ b/static/js/anime.ts @@ -0,0 +1,28 @@ +((): void => { + const toggleDropdown = (): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + dropdown.classList.toggle('open') + } + + ;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleDropdown + + document.addEventListener('click', (event: MouseEvent): void => { + 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') + } + }) +})() diff --git a/static/js/auth.js b/static/js/auth.js index a1ce6fc..015f152 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -1,23 +1,19 @@ -"use strict"; +// 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.'; - }); + 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); + return window.confirm(message); } -; window.copyRecoveryKey = copyRecoveryKey; window.confirmDangerAction = confirmDangerAction; diff --git a/static/js/auth.ts b/static/js/auth.ts new file mode 100644 index 0000000..dab5040 --- /dev/null +++ b/static/js/auth.ts @@ -0,0 +1,25 @@ +function copyRecoveryKey(keyElementId: string, feedbackElementId: string): void { + const keyElement = document.getElementById(keyElementId) + const feedbackElement = document.getElementById(feedbackElementId) + + if (!keyElement || !feedbackElement) { + return + } + + const key = keyElement.textContent || '' + navigator.clipboard + .writeText(key) + .then((): void => { + feedbackElement.textContent = 'Recovery key copied.' + }) + .catch((): void => { + feedbackElement.textContent = 'Copy failed. Select and copy manually.' + }) +} + +function confirmDangerAction(message: string): boolean { + return window.confirm(message) +} + +;(window as Window & { copyRecoveryKey?: typeof copyRecoveryKey; confirmDangerAction?: typeof confirmDangerAction }).copyRecoveryKey = copyRecoveryKey +;(window as Window & { copyRecoveryKey?: typeof copyRecoveryKey; confirmDangerAction?: typeof confirmDangerAction }).confirmDangerAction = confirmDangerAction diff --git a/static/js/discover.js b/static/js/discover.js index 8192ab5..75b06f4 100644 --- a/static/js/discover.js +++ b/static/js/discover.js @@ -1,23 +1,23 @@ -"use strict"; +// static/js/discover.ts (() => { - const setActiveTab = (clickedTab) => { - const group = clickedTab.closest('[data-tab-group="discover"]'); - if (!group) { - return; - } - const triggers = group.querySelectorAll('[data-tab-trigger]'); - triggers.forEach((tab) => tab.classList.remove('active')); - clickedTab.classList.add('active'); - }; - 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); - }); + const setActiveTab = (clickedTab) => { + const group = clickedTab.closest('[data-tab-group="discover"]'); + if (!group) { + return; + } + const triggers = group.querySelectorAll("[data-tab-trigger]"); + triggers.forEach((tab) => tab.classList.remove("active")); + clickedTab.classList.add("active"); + }; + 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/discover.ts b/static/js/discover.ts new file mode 100644 index 0000000..1ab193f --- /dev/null +++ b/static/js/discover.ts @@ -0,0 +1,26 @@ +((): void => { + const setActiveTab = (clickedTab: Element): void => { + const group = clickedTab.closest('[data-tab-group="discover"]') + if (!group) { + return + } + + const triggers = group.querySelectorAll('[data-tab-trigger]') + triggers.forEach((tab: Element): void => tab.classList.remove('active')) + clickedTab.classList.add('active') + } + + document.addEventListener('click', (event: MouseEvent): void => { + 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 index c3704c0..fcfbaac 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -1,109 +1,104 @@ -"use strict"; +// static/js/search.ts (() => { - const globalWindow = window; - if (globalWindow.searchInitialized) { - return; + const globalWindow = window; + if (globalWindow.searchInitialized) { + return; + } + globalWindow.searchInitialized = true; + let searchTimeout; + const searchInput = document.getElementById("search-input"); + const searchDropdown = document.getElementById("search-dropdown"); + if (!searchInput || !searchDropdown) { + return; + } + searchInput.addEventListener("input", (event) => { + if (searchTimeout) { + window.clearTimeout(searchTimeout); } - globalWindow.searchInitialized = true; - let searchTimeout; - const searchInput = document.getElementById('search-input'); - const searchDropdown = document.getElementById('search-dropdown'); - if (!searchInput || !searchDropdown) { - return; + const target = event.target; + if (!(target instanceof HTMLInputElement)) { + 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 = 'search-results'; - const title = document.createElement('div'); - title.className = 'search-results-title'; - title.textContent = 'Anime'; - searchResults.appendChild(title); - results.forEach((result) => { - const item = document.createElement('a'); - item.className = 'search-result-item'; - item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))); - if (isSafeImageUrl(result.image)) { - const img = document.createElement('img'); - img.className = 'search-result-thumb'; - img.setAttribute('src', result.image || ''); - img.setAttribute('alt', String(result.title || '')); - item.appendChild(img); - } - else { - const noImage = document.createElement('div'); - noImage.className = 'search-result-no-image'; - noImage.textContent = 'no image'; - item.appendChild(noImage); - } - const info = document.createElement('div'); - info.className = 'search-result-info'; - const itemTitle = document.createElement('div'); - itemTitle.className = 'search-result-title'; - itemTitle.textContent = String(result.title || ''); - info.appendChild(itemTitle); - const itemType = document.createElement('div'); - itemType.className = 'search-result-type'; - itemType.textContent = String(result.type || ''); - info.appendChild(itemType); - item.appendChild(info); - searchResults.appendChild(item); - }); - const viewAll = document.createElement('a'); - viewAll.className = 'search-result-view-all'; - 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('.header-search-wrapper')) { - 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; - } + 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 = "search-results"; + const title = document.createElement("div"); + title.className = "search-results-title"; + title.textContent = "Anime"; + searchResults.appendChild(title); + results.forEach((result) => { + const item = document.createElement("a"); + item.className = "search-result-item"; + item.setAttribute("href", "/anime/" + encodeURIComponent(String(result.id || ""))); + if (isSafeImageUrl(result.image)) { + const img = document.createElement("img"); + img.className = "search-result-thumb"; + img.setAttribute("src", result.image || ""); + img.setAttribute("alt", String(result.title || "")); + item.appendChild(img); + } else { + const noImage = document.createElement("div"); + noImage.className = "search-result-no-image"; + noImage.textContent = "no image"; + item.appendChild(noImage); + } + const info = document.createElement("div"); + info.className = "search-result-info"; + const itemTitle = document.createElement("div"); + itemTitle.className = "search-result-title"; + itemTitle.textContent = String(result.title || ""); + info.appendChild(itemTitle); + const itemType = document.createElement("div"); + itemType.className = "search-result-type"; + itemType.textContent = String(result.type || ""); + info.appendChild(itemType); + item.appendChild(info); + searchResults.appendChild(item); + }); + const viewAll = document.createElement("a"); + viewAll.className = "search-result-view-all"; + 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(".header-search-wrapper")) { + 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/search.ts b/static/js/search.ts new file mode 100644 index 0000000..2ee2a55 --- /dev/null +++ b/static/js/search.ts @@ -0,0 +1,127 @@ +((): void => { + const globalWindow = window as Window & { searchInitialized?: boolean } + if (globalWindow.searchInitialized) { + return + } + globalWindow.searchInitialized = true + + let searchTimeout: number | undefined + const searchInput = document.getElementById('search-input') as HTMLInputElement | null + const searchDropdown = document.getElementById('search-dropdown') + + if (!searchInput || !searchDropdown) { + return + } + + searchInput.addEventListener('input', (event: Event): void => { + 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((): void => { + fetch('/api/search-quick?q=' + encodeURIComponent(query)) + .then((res: Response) => res.json()) + .then((results: Array<{ id?: number; image?: string; title?: string; type?: string }>): void => { + if (!results || results.length === 0) { + searchDropdown.replaceChildren() + return + } + + const searchResults = document.createElement('div') + searchResults.className = 'search-results' + + const title = document.createElement('div') + title.className = 'search-results-title' + title.textContent = 'Anime' + searchResults.appendChild(title) + + results.forEach((result): void => { + const item = document.createElement('a') + item.className = 'search-result-item' + item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))) + + if (isSafeImageUrl(result.image)) { + const img = document.createElement('img') + img.className = 'search-result-thumb' + img.setAttribute('src', result.image || '') + img.setAttribute('alt', String(result.title || '')) + item.appendChild(img) + } else { + const noImage = document.createElement('div') + noImage.className = 'search-result-no-image' + noImage.textContent = 'no image' + item.appendChild(noImage) + } + + const info = document.createElement('div') + info.className = 'search-result-info' + + const itemTitle = document.createElement('div') + itemTitle.className = 'search-result-title' + itemTitle.textContent = String(result.title || '') + info.appendChild(itemTitle) + + const itemType = document.createElement('div') + itemType.className = 'search-result-type' + itemType.textContent = String(result.type || '') + info.appendChild(itemType) + + item.appendChild(info) + searchResults.appendChild(item) + }) + + const viewAll = document.createElement('a') + viewAll.className = 'search-result-view-all' + viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query)) + viewAll.textContent = 'View all results for ' + query + searchResults.appendChild(viewAll) + + searchDropdown.replaceChildren(searchResults) + }) + .catch((err: unknown): void => { + console.error('Search error:', err) + }) + }, 300) + }) + + searchInput.addEventListener('blur', (): void => { + window.setTimeout((): void => { + searchDropdown.replaceChildren() + }, 200) + }) + + document.addEventListener('click', (event: MouseEvent): void => { + const target = event.target + if (!(target instanceof Element)) { + return + } + + if (!target.closest('.header-search-wrapper')) { + searchDropdown.replaceChildren() + } + }) + + function isSafeImageUrl(rawUrl?: string): boolean { + 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 index 50ea426..1b1dafd 100644 --- a/static/js/timezone.js +++ b/static/js/timezone.js @@ -1,195 +1,195 @@ -"use strict"; +// 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 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 }; - 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('.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('.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); + 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(".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(".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); })(); diff --git a/static/js/timezone.ts b/static/js/timezone.ts new file mode 100644 index 0000000..6af5bd1 --- /dev/null +++ b/static/js/timezone.ts @@ -0,0 +1,246 @@ +((): void => { + const jstOffsetMinutes = 9 * 60 + + type ParsedBroadcast = { + day: string + hour: number + minute: number + } + + const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => { + 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: string | null): boolean => { + if (!timezone) { + return true + } + + const normalized = timezone.trim().toLowerCase() + return normalized === 'asia/tokyo' || normalized === 'jst' + } + + const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => { + 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: string | null): ParsedBroadcast | null => { + 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: string): number | null => { + const key = day.trim().toLowerCase().replace(/s$/, '') + const days: Record = { + 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: ParsedBroadcast, localOffsetMinutes: number): string | null => { + 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: ParsedBroadcast): Date | null => { + 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: number, unit: Intl.RelativeTimeFormatUnit): string => { + 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: Date): string => { + 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: Date): string => { + const formatter = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + }) + return formatter.format(date) + } + + const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => { + const card = node.closest('.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: Element, localOffsetMinutes: number): void => { + const card = node.closest('.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 = (): void => { + const localOffsetMinutes = -new Date().getTimezoneOffset() + const nodes = document.querySelectorAll('[data-jst-text]') + nodes.forEach((node: Element): void => updateNode(node, localOffsetMinutes)) + } + + document.addEventListener('DOMContentLoaded', updateAll) + document.body.addEventListener('htmx:afterSwap', updateAll) +})() diff --git a/tsconfig.json b/tsconfig.json index 9e6f4af..2ac835b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,13 +5,13 @@ "moduleResolution": "Bundler", "strict": true, "noEmitOnError": true, - "outDir": "./static/js", - "rootDir": "./static/ts", + "allowJs": false, + "noEmit": true, "sourceMap": false, "removeComments": false, "skipLibCheck": true }, "include": [ - "static/ts/**/*.ts" + "static/js/**/*.ts" ] }