v2.2.0: New features, performance improvements

This commit is contained in:
Bogdan Stefan 2026-07-05 15:04:49 +01:00
parent d66b849bc9
commit 8a403d59e5
27 changed files with 3287 additions and 366 deletions

View file

@ -1,6 +1,44 @@
# Changelog
All notable changes to WatchLog are documented here.
## [2.2.0] - 2026-07-05
##### Added
- **Per-list tab color** - each custom list tab can now be assigned a color, applied to its tab name. Set it via a color picker in Table settings (renamed from "Edit columns").
- **Per-option colors for select columns** - each option of a select-type column can be given its own color, defined per table; the colored badge reflects the option's color in both the cell and the selection dropdown.
- **Custom colors everywhere** - the color picker now offers the 8 presets plus a full-spectrum custom color, with a built-in reset-to-default. Available for Custom Lists tab/option colors and Reading field colors alike.
- **Column resizing** - Columns now can be resized
- **TSV export** - Export now offers a format chooser: copy as TSV (pastes cleanly into Google Sheets / Excel as columns) or as Markdown (for pasting into notes).
- **Import rows** - a new Import button lets you paste tab-separated data copied from a spreadsheet to append rows to the current list
- **Select-all tick** - a tick in the table header checks or unchecks all visible rows at once.
- Now you can select default content type for adding titles - pick a fixed type or `* Last type used` (remembers your last choice) in Settings → Watchlist.
- Richer note frontmatter — Watchlist notes now carry review, dates, episode duration, IDs, community rating, a single resolved poster, and more; Reading notes add cover, external link, and modified date. Makes notes portable and `.base`-friendly.
- "Rewrite frontmatter on all notes" button (Settings → General) to apply the enriched frontmatter to existing notes - chunked, cancelable, preserves note bodies.
- Cover thumbnails in search results when adding titles, books, and manga, so you can tell same-named entries apart.
- **Director & cast fields** on Watchlist titles (`director`, `cast` — both `string[]`). Cast is hardcoded-capped at 4 across every source, so a title looks identical regardless of which API supplied it.
- **Lazy backfill on modal open**: opening `TitleDetailModal` for an older title missing the fields triggers a one-time fetch keyed off the IMDb id in its external link (prefers active API, falls back to the other keyed one). Persists through a new `DataManager.updateCredits()` that writes both `data.json` and the note frontmatter in one operation — not the silent poster path.
- **UI**: shared `renderCreditsRows` renders Director/Cast in the modal (below the stats box) and in the List view expanded row (under the "Not started" line). Names render as clickable chips (theme-variable CSS).
- **Click-to-filter**: clicking a name routes it into the existing search bar — visible, clearable, and matching typed-search behaviour.
- **YouTube trailers** — auto-fetched at add-time (TMDB, Jikan, AniList; OMDb has none), stored per title, shown as a red YouTube icon on the detail row and saved to note frontmatter. Manual override field in Edit. Existing titles get a refresh button to fetch on demand.
- Added the cover image to the left of the title detail modal (Movie/Series), with the full header block (title + badges + stats) moved to its right, mirroring the Reading modal layout. Letter-placeholder fallback when no poster exists.
##### Improved
- Add-title form compacted (Episodes/Duration and Status/Priority paired onto single rows), giving the search results more room.
- Dashboard grid now adapts to panel width on mobile (3→2→1 columns); desktop unchanged.
- Mobile tab bar switched to icons so all tabs fit on one row without scrolling.
##### Changed
- Switching a column to **select** now populates its options from the distinct values already present in that column's cells (in order of first appearance), preserving existing cell values instead of resurrecting stale options.
- **Dashboard** — default card style is now rectangles for new installs.
- **Edit modal** — Status/Priority, Total episodes/Ep. duration, and Date started/Date finished now share rows; added the "Add to group / create new group" section.
##### Fixed
- **Cover art accuracy** - posters are now matched by media type (Movie vs TV Show), preferring the correct release year, and resolved directly via IMDb id when available, instead of blindly taking the first multi-search result. Fixes cases where the wrong image was pulled (e.g. a film getting its TV series' poster). Applies to both TMDB and OMDb.
- Watchlist notes no longer overwrite content you wrote by hand directly in the `.md` file.
- Title frontmatter now escapes special characters - titles or links containing `:` no longer break the YAML.
- Dashboard no longer overflows off-screen on mobile.
- Fixed the whole view dragging sideways like a loose sheet on mobile.
- **Cards view** — selection now works (click a card to select, 3px purple border on selected); fixed the flicker when entering/exiting selection mode (no more full tab rebuild).
- Fixed the progressively-climbing CLS on scroll in Cards View.
## [2.1.0] - 2026-06-17

View file

@ -26,9 +26,31 @@ export default tseslint.config(
...obsidianmd.configs.recommended,
{
// Cosmetic-only: keep UI sentence-case visible without failing the lint.
// ignoreWords/ignoreRegex teach the rule this plugin's proper nouns
// (tab, modal and feature names) and format-mask placeholders; genuine
// sentence-case violations are still reported.
plugins: { obsidianmd },
rules: {
'obsidianmd/ui/sentence-case': 'warn',
'obsidianmd/ui/sentence-case': ['warn', {
ignoreWords: [
// Plugin / feature names
'WatchLog', 'Watchlist', 'Upcoming', 'Reading', 'Cards',
'Drafts', 'Custom', 'Lists', 'Books', 'Manga',
'Notes', 'Quotes', 'Regenerate', 'Settings', 'Add', 'Last',
// External services / acronyms not in the rule defaults
'Google', 'MAL', 'TSV', 'URLs', 'APIs',
],
ignoreRegex: [
'^DD/MM/YYYY$', // date format mask
'^https://', // URL placeholder
'^Season \\d', // season-spec example placeholder
'^p\\. 123', // quote-location example placeholder
'^APIs & Services', // Google Cloud console path
'"N due"', // N is a number placeholder
'^\\* Last type used$', // sentinel option; * confuses sentence-start detection
'"Last type used"', // quotes that sentinel option's label
],
}],
},
},
globalIgnores([

90
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "watchlog",
"name": "WatchLog",
"version": "2.1.0",
"version": "2.2.0",
"minAppVersion": "1.7.2",
"description": "Track your anime, movies, books, manga and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.",
"author": "BogdanS",

View file

@ -1,6 +1,6 @@
{
"name": "watchlog-plugin",
"version": "2.1.0",
"version": "2.2.0",
"description": "Track your anime, movies, books, manga and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.",
"main": "main.js",
"type": "module",

View file

@ -87,6 +87,9 @@ export class AddFromUrlModal extends Modal {
releaseDate: result.releaseDate,
link: result.url,
seasons: result.seasons,
trailerUrl: result.trailerUrl ?? '',
director: result.director,
cast: result.cast,
}).open();
} finally {
if (this.addBtn) {

View file

@ -3,6 +3,7 @@ import type WatchLogPlugin from './main';
import type { ReadingDataManager } from './ReadingDataManager';
import type { BookSearchResult, MangaSearchResult } from './ApiService';
import { googleBooksErrorMessage } from './ApiService';
import { renderSearchThumbnail } from './SearchThumbnail';
import {
Book,
Manga,
@ -333,9 +334,11 @@ export class AddReadingModal extends Modal {
}
for (const r of results) {
const item = this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-item' });
this.renderLookupItemTitle(item, r.title, r.url);
renderSearchThumbnail(item, r.coverUrl, r.title, '📖');
const text = item.createDiv({ cls: 'wl-reading-lookup-item-text' });
this.renderLookupItemTitle(text, r.title, r.url);
const meta = [r.author || 'Unknown author', r.year ? String(r.year) : ''].filter(Boolean).join(' · ');
item.createDiv({ cls: 'wl-reading-lookup-item-meta', text: meta });
text.createDiv({ cls: 'wl-reading-lookup-item-meta', text: meta });
item.addEventListener('click', () => this.applyBookResult(r));
}
}
@ -363,9 +366,11 @@ export class AddReadingModal extends Modal {
}
for (const r of results) {
const item = this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-item' });
this.renderLookupItemTitle(item, r.title, r.url);
renderSearchThumbnail(item, r.coverUrl, r.title, '📓');
const text = item.createDiv({ cls: 'wl-reading-lookup-item-text' });
this.renderLookupItemTitle(text, r.title, r.url);
const meta = [r.author || 'Unknown author', r.year ? String(r.year) : ''].filter(Boolean).join(' · ');
item.createDiv({ cls: 'wl-reading-lookup-item-meta', text: meta });
text.createDiv({ cls: 'wl-reading-lookup-item-meta', text: meta });
item.addEventListener('click', () => this.applyMangaResult(r));
}
}

View file

@ -9,6 +9,7 @@ import type {
Season,
} from './types';
import { formatDateDisplay, parseDateInput, parseReleaseDateInput, getApiGroupForType } from './types';
import { renderSearchThumbnail } from './SearchThumbnail';
type SearchResult = AnimeSearchResult | MediaSearchResult;
@ -42,6 +43,11 @@ export class AddTitleModal extends Modal {
private fieldDateStarted = '';
private fieldMalId: number | null = null;
private fieldAnilistId: number | null = null;
// Trailer resolved at selection ('' unfetched, 'none' source w/o trailer, or URL).
private fieldTrailerUrl = '';
// Credits resolved at selection ([] unfetched, ['none'] unavailable, or names).
private fieldDirector: string[] = [];
private fieldCast: string[] = [];
private skipDuplicateCheck = false;
private duplicateWarningEl: HTMLElement | null = null;
@ -68,12 +74,18 @@ export class AddTitleModal extends Modal {
releaseDate: string;
link: string;
seasons: Season[];
trailerUrl?: string;
director?: string[];
cast?: string[];
},
) {
super(app);
this.plugin = plugin;
this.dataManager = dataManager;
this.onAdded = onAdded;
// Preselect the resolved default type; a prefill type (e.g. from a URL
// detection) takes precedence and overrides it below.
this.selectedType = this.plugin.resolveDefaultAddType() || this.selectedType;
if (prefill) {
this.selectedType = prefill.type;
if (prefill.title) {
@ -88,6 +100,9 @@ export class AddTitleModal extends Modal {
this.fieldReleaseDate = prefill.releaseDate;
this.fieldLink = prefill.link;
this.fieldSeasons = prefill.seasons;
if (prefill.trailerUrl !== undefined) this.fieldTrailerUrl = prefill.trailerUrl;
if (prefill.director !== undefined) this.fieldDirector = prefill.director;
if (prefill.cast !== undefined) this.fieldCast = prefill.cast;
}
}
@ -210,12 +225,14 @@ export class AddTitleModal extends Modal {
if (this.searchResults.length === 0) return;
this.searchResults.forEach((result, idx) => {
const item = this.resultsEl!.createDiv({ cls: 'wl-result-item' });
const item = this.resultsEl!.createDiv({ cls: 'wl-result-item wl-has-thumb' });
renderSearchThumbnail(item, result.posterUrl, result.title, '🎬');
const text = item.createDiv({ cls: 'wl-result-text' });
const epText = isAnimeResult(result)
? `${result.episodes} eps`
: result.episodes > 0 ? `${result.episodes} eps` : result.mediaType;
item.createDiv({ cls: 'wl-result-title', text: result.title });
item.createDiv({ cls: 'wl-result-meta', text: `${epText} · ${result.releaseDate}` });
text.createDiv({ cls: 'wl-result-title', text: result.title });
text.createDiv({ cls: 'wl-result-meta', text: `${epText} · ${result.releaseDate}` });
item.dataset['idx'] = String(idx);
item.addEventListener('click', () => void this.selectResult(result, item));
});
@ -245,6 +262,9 @@ export class AddTitleModal extends Modal {
this.fieldReleaseDate = full.releaseDate;
this.fieldLink = full.url;
this.fieldSeasons = full.seasons;
this.fieldTrailerUrl = full.trailerUrl ?? '';
this.fieldDirector = full.director ?? [];
this.fieldCast = full.cast ?? [];
}
} else if (!isAnimeResult(result) && result.mediaType === 'movie') {
const full = activeApi === 'TMDB'
@ -258,6 +278,9 @@ export class AddTitleModal extends Modal {
this.fieldReleaseDate = full.releaseDate;
this.fieldLink = full.url;
this.fieldSeasons = full.seasons;
this.fieldTrailerUrl = full.trailerUrl ?? '';
this.fieldDirector = full.director ?? [];
this.fieldCast = full.cast ?? [];
}
} else {
const anime = result as AnimeSearchResult;
@ -267,6 +290,10 @@ export class AddTitleModal extends Modal {
this.fieldReleaseDate = anime.releaseDate;
this.fieldLink = anime.url;
this.fieldSeasons = anime.seasons;
this.fieldTrailerUrl = anime.trailerUrl ?? '';
// Anime sources don't carry credits — clear any stale movie/TV selection.
this.fieldDirector = [];
this.fieldCast = [];
if (anime.anilistId && anime.anilistId > 0) {
this.fieldAnilistId = anime.anilistId;
this.fieldMalId = null;
@ -282,6 +309,10 @@ export class AddTitleModal extends Modal {
this.fieldReleaseDate = result.releaseDate;
this.fieldLink = result.url;
this.fieldSeasons = result.seasons;
// Anime search results already carry a trailer; movie/TV list rows do not.
this.fieldTrailerUrl = result.trailerUrl ?? '';
this.fieldDirector = !isAnimeResult(result) ? (result.director ?? []) : [];
this.fieldCast = !isAnimeResult(result) ? (result.cast ?? []) : [];
}
this.renderForm();
@ -297,6 +328,17 @@ export class AddTitleModal extends Modal {
return row;
};
// A paired-row cell: [label][fixed-width input]. The left cell keeps the
// standard label width so its input aligns with the full-width fields; the
// right cell anchors to the row's right edge (see wl-modal-pair-cell-right).
const makeCell = (row: HTMLElement, label: string, right = false): HTMLElement => {
const cell = row.createDiv({
cls: right ? 'wl-modal-pair-cell wl-modal-pair-cell-right' : 'wl-modal-pair-cell',
});
cell.createSpan({ cls: 'wl-modal-label', text: label });
return cell;
};
// Title
const titleRow = makeRow('Title');
const titleInput = titleRow.createEl('input', {
@ -306,19 +348,19 @@ export class AddTitleModal extends Modal {
titleInput.value = this.fieldTitle;
titleInput.addEventListener('input', () => { this.fieldTitle = titleInput.value; });
// Episodes
const epsRow = makeRow('Episodes');
const epsInput = epsRow.createEl('input', {
cls: 'wl-modal-input wl-modal-input-sm',
// Episodes + Ep. duration paired 50/50 on one row to reclaim vertical space.
const epsDurRow = this.formEl.createDiv({ cls: 'wl-modal-row wl-modal-row-pair' });
const epsCell = makeCell(epsDurRow, 'Episodes');
const epsInput = epsCell.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'number', min: '0', placeholder: '0' },
});
epsInput.value = String(this.fieldEpisodes);
epsInput.addEventListener('input', () => { this.fieldEpisodes = parseInt(epsInput.value) || 0; });
// Duration
const durRow = makeRow('Ep. duration (min)');
const durInput = durRow.createEl('input', {
cls: 'wl-modal-input wl-modal-input-sm',
const durCell = makeCell(epsDurRow, 'Ep. duration (min)', true);
const durInput = durCell.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'number', min: '0', placeholder: '24' },
});
durInput.value = String(this.fieldDuration);
@ -361,18 +403,19 @@ export class AddTitleModal extends Modal {
linkInput.value = this.fieldLink;
linkInput.addEventListener('input', () => { this.fieldLink = linkInput.value; });
// Status (exclude "To be released" — it is auto-assigned based on releaseDate)
const statusRow = makeRow('Status');
const statusSelect = statusRow.createEl('select', { cls: 'wl-select' });
// Status + Priority paired 50/50 on one row to reclaim vertical space.
// (Status excludes "To be released" — it is auto-assigned based on releaseDate.)
const statusPriRow = this.formEl.createDiv({ cls: 'wl-modal-row wl-modal-row-pair' });
const statusCell = makeCell(statusPriRow, 'Status');
const statusSelect = statusCell.createEl('select', { cls: 'wl-select' });
for (const s of this.plugin.settings.statuses.filter((s) => s.name !== 'To be released')) {
const opt = statusSelect.createEl('option', { text: s.name, value: s.name });
if (s.name === this.fieldStatus) opt.selected = true;
}
statusSelect.addEventListener('change', () => { this.fieldStatus = statusSelect.value; });
// Priority
const priRow = makeRow('Priority');
const priSelect = priRow.createEl('select', { cls: 'wl-select' });
const priCell = makeCell(statusPriRow, 'Priority', true);
const priSelect = priCell.createEl('select', { cls: 'wl-select' });
for (const p of this.plugin.settings.priorities) {
const opt = priSelect.createEl('option', { text: p.name, value: p.name });
if (p.name === this.fieldPriority) opt.selected = true;
@ -511,6 +554,12 @@ export class AddTitleModal extends Modal {
externalLink: this.fieldLink,
seasons: this.fieldSeasons,
watchedEpisodes: [],
posterUrl: '',
trailerUrl: this.fieldTrailerUrl,
manualTrailerUrl: '',
// Empty stays absent (= unfetched) so the detail modal's lazy backfill runs.
...(this.fieldDirector.length > 0 ? { director: this.fieldDirector } : {}),
...(this.fieldCast.length > 0 ? { cast: this.fieldCast } : {}),
...(this.fieldMalId !== null ? { malId: this.fieldMalId } : {}),
...(this.fieldAnilistId !== null ? { anilistId: this.fieldAnilistId } : {}),
};
@ -525,6 +574,10 @@ export class AddTitleModal extends Modal {
await this.dataManager.addTitle(entry);
// Remember the type that was actually used so "Last type used" can follow it.
this.plugin.settings.lastAddedType = entry.type;
void this.plugin.saveSettings();
// Fire-and-forget community rating fetch (no await — non-blocking)
void (async () => {
const animeSource = this.plugin.settings.animeApiSource ?? 'jikan';

View file

@ -12,11 +12,14 @@ import type {
TmdbTvDetail,
TmdbExternalIds,
TmdbFindResult,
TmdbVideo,
TmdbCreditsResult,
Season,
AniListMedia,
AniListSearchResponse,
AniListMediaResponse,
} from './types';
import { normalizeCreditsForStore } from './types';
const JIKAN_BASE = 'https://api.jikan.moe/v4';
const OMDB_BASE = 'https://www.omdbapi.com';
@ -27,6 +30,48 @@ const GOOGLE_BOOKS_BASE = 'https://www.googleapis.com/books/v1';
const JIKAN_RATE_LIMIT_MS = 400;
const API_TIMEOUT_MS = 8000;
/** Canonical YouTube watch URL from a raw video id. */
function youtubeWatchUrl(id: string): string {
return `https://www.youtube.com/watch?v=${id}`;
}
/**
* Extracts a YouTube video id from any of the shapes providers hand back
* `watch?v=ID`, `youtu.be/ID`, or `/embed/ID` (Jikan frequently ships only the
* embed URL while leaving youtube_id / url null). Empty string when none match.
*/
function extractYoutubeId(url: string | null | undefined): string {
if (!url) return '';
const m = url.match(/(?:[?&]v=|\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
return m?.[1] ?? '';
}
/**
* Splits an OMDb comma-separated name string ("Director" / "Actors") into a
* normalized stored list: trimmed names capped at MAX_CAST, or ['none'] when
* the field is absent / 'N/A' so both APIs yield the same shape.
*/
function omdbNamesToCredits(s: string | undefined): string[] {
if (!s || s === 'N/A') return normalizeCreditsForStore([]);
return normalizeCreditsForStore(s.split(','));
}
/**
* Maps a TMDB `credits` payload to the stored { director, cast } shape:
* directors from crew entries with the Director job, cast from the (already
* billing-ordered) cast list both normalized and capped like OMDb's.
*/
function tmdbCreditsToStored(credits: TmdbCreditsResult | undefined): { director: string[]; cast: string[] } {
const director = (credits?.crew ?? [])
.filter((c) => c.job === 'Director' && c.name)
.map((c) => c.name!);
const cast = (credits?.cast ?? []).map((c) => c.name ?? '');
return {
director: normalizeCreditsForStore(director),
cast: normalizeCreditsForStore(cast),
};
}
export interface BookSearchResult {
title: string;
author: string;
@ -266,6 +311,10 @@ export class ApiService {
averageScore: media.averageScore ?? undefined,
genres: media.genres ?? undefined,
posterUrl: media.coverImage?.large ?? media.coverImage?.medium ?? undefined,
trailerUrl:
media.trailer?.site === 'youtube' && media.trailer.id
? youtubeWatchUrl(media.trailer.id)
: 'none',
};
}
@ -287,6 +336,7 @@ export class ApiService {
coverImage { large medium }
description
genres
trailer { id site }
}
}
}
@ -315,6 +365,7 @@ export class ApiService {
averageScore
popularity
coverImage { large }
trailer { id site }
nextAiringEpisode {
airingAt
episode
@ -346,6 +397,16 @@ export class ApiService {
}
}
/** Resolves a Jikan `trailer` object to a YouTube URL, or 'none' when absent. */
private jikanTrailerUrl(trailer: JikanAnime['trailer']): string {
if (!trailer) return 'none';
const id =
(trailer.youtube_id ?? '') ||
extractYoutubeId(trailer.url) ||
extractYoutubeId(trailer.embed_url);
return id ? youtubeWatchUrl(id) : 'none';
}
private mapJikanAnime(anime: JikanAnime): AnimeSearchResult {
const durationStr = anime.duration ?? '24 min per ep';
const durationMatch = durationStr.match(/(\d+)\s*min/);
@ -361,6 +422,8 @@ export class ApiService {
releaseDate: anime.aired?.from ? (anime.aired.from.split('T')[0] ?? '') : '',
url: anime.url,
seasons,
posterUrl: anime.images?.jpg?.small_image_url ?? anime.images?.jpg?.image_url ?? undefined,
trailerUrl: this.jikanTrailerUrl(anime.trailer),
};
}
@ -403,6 +466,9 @@ export class ApiService {
releaseDate: this.parseOmdbDate(data.Released ?? data.Year),
url: `https://www.imdb.com/title/${data.imdbID}`,
seasons: [{ name: 'Movie', episodes: 1, offset: 0 }],
trailerUrl: 'none', // OMDb does not provide trailers.
director: omdbNamesToCredits(data.Director),
cast: omdbNamesToCredits(data.Actors),
};
} catch {
return null;
@ -437,6 +503,9 @@ export class ApiService {
releaseDate: this.parseOmdbDate(data.Released ?? data.Year),
url: `https://www.imdb.com/title/${data.imdbID}`,
seasons,
trailerUrl: 'none', // OMDb does not provide trailers.
director: omdbNamesToCredits(data.Director),
cast: omdbNamesToCredits(data.Actors),
};
} catch {
return null;
@ -479,6 +548,17 @@ export class ApiService {
}
}
/** Detail lookup for a single anime's trailer (Jikan /anime/{mal_id}). Returns a URL or 'none'. */
async getJikanTrailerByMalId(malId: number): Promise<string> {
try {
const url = `${JIKAN_BASE}/anime/${malId}`;
const data = (await this.throttleJikan(() => this.fetchWithTimeout(url))) as { data?: JikanAnime };
return this.jikanTrailerUrl(data.data?.trailer);
} catch {
return 'none';
}
}
/**
* Converts OMDb "DD Mon YYYY" dates (e.g. "08 Feb 2026") to YYYY-MM-DD.
* Returns the input unchanged if it doesn't match that pattern (e.g. year-only "2026").
@ -527,17 +607,34 @@ export class ApiService {
releaseDate: item.release_date ?? item.first_air_date ?? '',
url: '',
seasons: [],
posterUrl: item.poster_path ? `https://image.tmdb.org/t/p/w92${item.poster_path}` : undefined,
}));
} catch {
return [];
}
}
/**
* Picks the best trailer from a TMDB `videos.results` list: an official YouTube
* Trailer first, then any YouTube Trailer, then any YouTube Teaser. Returns a
* YouTube watch URL, or 'none' when nothing suitable exists.
*/
private pickTmdbTrailer(videos: TmdbVideo[] | undefined): string {
const yt = (videos ?? []).filter((v) => v.site === 'YouTube' && v.key);
const pick =
yt.find((v) => v.type === 'Trailer' && v.official === true) ??
yt.find((v) => v.type === 'Trailer') ??
yt.find((v) => v.type === 'Teaser');
return pick ? youtubeWatchUrl(pick.key) : 'none';
}
async getTmdbMovieDetails(tmdbId: string): Promise<MediaSearchResult | null> {
if (!this.tmdbApiKey) return null;
try {
// append_to_response=videos,credits folds the trailer + director/cast
// lookups into the detail call (no extra round-trips).
const [detailData, extData] = await Promise.all([
this.fetchWithTimeout(`${TMDB_BASE}/movie/${tmdbId}`, this.tmdbHeaders()),
this.fetchWithTimeout(`${TMDB_BASE}/movie/${tmdbId}?append_to_response=videos,credits`, this.tmdbHeaders()),
this.fetchWithTimeout(`${TMDB_BASE}/movie/${tmdbId}/external_ids`, this.tmdbHeaders()),
]);
const detail = detailData as TmdbMovieDetail;
@ -552,6 +649,8 @@ export class ApiService {
releaseDate: detail.release_date ?? '',
url: imdbId ? `https://www.imdb.com/title/${imdbId}` : '',
seasons: [{ name: 'Movie', episodes: 1, offset: 0 }],
trailerUrl: this.pickTmdbTrailer(detail.videos?.results),
...tmdbCreditsToStored(detail.credits),
};
} catch {
return null;
@ -562,7 +661,7 @@ export class ApiService {
if (!this.tmdbApiKey) return null;
try {
const [detailData, extData] = await Promise.all([
this.fetchWithTimeout(`${TMDB_BASE}/tv/${tmdbId}`, this.tmdbHeaders()),
this.fetchWithTimeout(`${TMDB_BASE}/tv/${tmdbId}?append_to_response=videos,credits`, this.tmdbHeaders()),
this.fetchWithTimeout(`${TMDB_BASE}/tv/${tmdbId}/external_ids`, this.tmdbHeaders()),
]);
const detail = detailData as TmdbTvDetail;
@ -587,6 +686,8 @@ export class ApiService {
releaseDate: detail.first_air_date ?? '',
url: imdbId ? `https://www.imdb.com/title/${imdbId}` : '',
seasons,
trailerUrl: this.pickTmdbTrailer(detail.videos?.results),
...tmdbCreditsToStored(detail.credits),
};
} catch {
return null;
@ -754,6 +855,111 @@ export class ApiService {
return null;
}
/**
* Re-fetches the trailer for an existing title from its source, reusing the same
* paths as add-time: AniList/Jikan for anime (honouring `animeApiSource`, falling
* back to the other ID), TMDB-by-IMDb for movies/TV. OMDb (and anything else)
* yields 'none'. Returns a YouTube watch URL or the sentinel 'none'.
*/
async fetchTrailer(
title: WatchLogTitle,
animeApiSource: 'jikan' | 'anilist' = 'jikan',
apiGroup: 'anime' | 'movie' | '' = '',
): Promise<string> {
const resolvedGroup: 'anime' | 'movie' | '' =
apiGroup || (title.type === 'Anime' ? 'anime' : title.type === 'Movie' || title.type === 'TV Show' || title.type === 'TvShow' ? 'movie' : '');
if (resolvedGroup === 'anime') {
const hasMal = (title.malId ?? 0) > 0;
const hasAniList = (title.anilistId ?? 0) > 0;
const tryAniList = async (): Promise<string> => {
if (!hasAniList) return 'none';
const media = await this.getAniListById(title.anilistId!);
return media?.trailer?.site === 'youtube' && media.trailer.id
? youtubeWatchUrl(media.trailer.id)
: 'none';
};
const tryJikan = async (): Promise<string> => {
if (!hasMal) return 'none';
return this.getJikanTrailerByMalId(title.malId!);
};
const order = animeApiSource === 'anilist' ? [tryAniList, tryJikan] : [tryJikan, tryAniList];
for (const attempt of order) {
const url = await attempt();
if (url && url !== 'none') return url;
}
return 'none';
}
if (resolvedGroup === 'movie') {
const imdbId = this.extractImdbId(title.externalLink ?? '');
// Only TMDB carries trailers; OMDb never does.
if (imdbId && this.tmdbApiKey) {
const full = await this.getTmdbByImdbId(imdbId);
if (full?.trailerUrl && full.trailerUrl !== 'none') return full.trailerUrl;
}
return 'none';
}
return 'none';
}
/**
* One-time director/cast backfill for an existing title, keyed off the IMDb id
* in its stored externalLink. Prefers the active API, falls back to the other
* one when it has a key. Returns normalized stored lists (names or ['none']),
* or null when there is no usable id / no keyed API / the requests failed
* the caller marks the fields 'none' in that case so it never re-attempts.
*/
async fetchCredits(
title: WatchLogTitle,
activeApi: 'OMDb' | 'TMDB' = 'OMDb',
): Promise<{ director: string[]; cast: string[] } | null> {
const imdbId = this.extractImdbId(title.externalLink ?? '');
if (!imdbId) return null;
// OMDb: Director/Actors ride in the plain detail response — one cheap call
// (deliberately NOT getOmdbTvDetails, which fans out per-season requests).
const tryOmdb = async (): Promise<{ director: string[]; cast: string[] } | null> => {
if (!this.omdbApiKey) return null;
try {
const url = `${OMDB_BASE}/?i=${encodeURIComponent(imdbId)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;
const data = (await this.fetchWithTimeout(url)) as OmdbDetailResponse;
if (data.Response === 'False' || !data.imdbID) return null;
return {
director: omdbNamesToCredits(data.Director),
cast: omdbNamesToCredits(data.Actors),
};
} catch {
return null;
}
};
// TMDB: resolve the IMDb id via /find, then hit the lean /credits endpoint.
const tryTmdb = async (): Promise<{ director: string[]; cast: string[] } | null> => {
if (!this.tmdbApiKey) return null;
try {
const findUrl = `${TMDB_BASE}/find/${encodeURIComponent(imdbId)}?external_source=imdb_id`;
const found = (await this.fetchWithTimeout(findUrl, this.tmdbHeaders())) as TmdbFindResult;
const movieId = found.movie_results?.[0]?.id;
const tvId = found.tv_results?.[0]?.id;
const path = movieId !== undefined ? `movie/${movieId}` : tvId !== undefined ? `tv/${tvId}` : null;
if (!path) return null;
const credits = (await this.fetchWithTimeout(`${TMDB_BASE}/${path}/credits`, this.tmdbHeaders())) as TmdbCreditsResult;
return tmdbCreditsToStored(credits);
} catch {
return null;
}
};
const order = activeApi === 'TMDB' ? [tryTmdb, tryOmdb] : [tryOmdb, tryTmdb];
for (const attempt of order) {
const result = await attempt();
if (result) return result;
}
return null;
}
// ─── Google Books (Books) ─────────────────────────────────────────────────────
/**

169
src/ColorPicker.ts Normal file
View file

@ -0,0 +1,169 @@
import { FIELD_COLORS, DEFAULT_FIELD_COLOR } from './types';
// ─────────────────────────────────────────────────────────────────────────────
// ColorPicker — shared swatch-picker UI + fill/border colour derivation.
//
// The Reading subsystem historically stored a colour as the "600 stop" hex and
// looked up a paired "50 stop" light tint from a hardcoded table. That only
// worked for the 8 presets. These helpers derive the fill/border variants from
// ANY hex (so custom colours render correctly) while keeping the exact preset
// tints for the 8 built-ins — behaviour for presets is unchanged.
// ─────────────────────────────────────────────────────────────────────────────
/** Normalize hex input to lowercase `#rrggbb`, expanding `#rgb`. Returns the
* default field colour for anything unparseable. */
function normalizeHex(input: string): string {
const hex = (input ?? '').trim().toLowerCase();
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex);
if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
if (/^#[0-9a-f]{6}$/.test(hex)) return hex;
return DEFAULT_FIELD_COLOR.toLowerCase();
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const n = normalizeHex(hex);
return {
r: parseInt(n.slice(1, 3), 16),
g: parseInt(n.slice(3, 5), 16),
b: parseInt(n.slice(5, 7), 16),
};
}
function toHexComponent(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
}
/** Mix a colour toward white. `amount` is the fraction of the colour kept. */
function mixWithWhite(hex: string, amount: number): string {
const { r, g, b } = hexToRgb(hex);
const mix = (c: number): number => 255 * (1 - amount) + c * amount;
return `#${toHexComponent(mix(r))}${toHexComponent(mix(g))}${toHexComponent(mix(b))}`;
}
/**
* Light fill tint for a given colour. For the 8 presets this returns the exact
* authored `color50` so existing fields render identically; for any other hex
* it derives a light tint programmatically.
*/
export function deriveFillColor(color: string): string {
const norm = normalizeHex(color);
for (const { color600, color50 } of FIELD_COLORS) {
if (normalizeHex(color600) === norm) return color50;
}
return mixWithWhite(norm, 0.12);
}
/**
* Border / strong-accent colour for a given colour. This is the colour itself
* (the historical "600 stop"), normalized so it works for arbitrary hex input.
*/
export function deriveBorderColor(color: string): string {
return normalizeHex(color);
}
export interface ColorPickerOptions {
/** Element the palette is positioned beneath. */
anchor: HTMLElement;
/** Element the palette is appended to (positioned `fixed`, so any container works). */
container: HTMLElement;
/** Currently selected colour. */
current: string;
/** When true, offer a full-spectrum custom-colour swatch in addition to the 8 presets. */
allowCustom: boolean;
/** Called with the chosen colour (a `#rrggbb` hex), or `null` when the user
* picks the clear/"no colour" swatch (consumer should revert to its unset state). */
onPick: (color: string | null) => void;
}
/**
* Opens the shared colour-swatch picker the 8 presets plus, when enabled, a
* custom full-spectrum swatch (native colour input / hex). Matches the visual
* style of the original Reading swatch palette.
*/
export function openColorPicker(opts: ColorPickerOptions): void {
const { anchor, container, current, allowCustom, onPick } = opts;
// Remove any palette already open in this container.
container.querySelectorAll('.wl-reading-col-palette').forEach((el) => el.remove());
const rect = anchor.getBoundingClientRect();
const palette = container.createDiv({ cls: 'wl-reading-col-palette' });
palette.style.top = `${rect.bottom + 4}px`;
palette.style.left = `${rect.left}px`;
// Capture the owning document once so add/remove can't desync across popout windows.
const doc = container.ownerDocument;
const normCurrent = normalizeHex(current);
const close = (e: MouseEvent): void => {
if (!palette.contains(e.target as Node) && e.target !== anchor) {
palette.remove();
doc.removeEventListener('mousedown', close, true);
}
};
const dismiss = (): void => {
palette.remove();
doc.removeEventListener('mousedown', close, true);
};
let matchedPreset = false;
for (const { color600, color50 } of FIELD_COLORS) {
const swatch = palette.createEl('button', { cls: 'wl-reading-col-palette-swatch' });
swatch.style.backgroundColor = color50;
swatch.style.borderColor = color600;
if (normalizeHex(color600) === normCurrent) {
swatch.addClass('is-active');
matchedPreset = true;
}
swatch.title = color600;
swatch.addEventListener('click', (e) => {
e.stopPropagation();
dismiss();
onPick(color600);
});
}
if (allowCustom) {
const customSwatch = palette.createEl('button', {
cls: 'wl-reading-col-palette-swatch wl-color-custom-swatch',
});
customSwatch.title = 'Custom color…';
if (matchedPreset) {
// Current colour is a preset: present the custom swatch as a rainbow invite.
customSwatch.addClass('wl-color-custom-rainbow');
} else {
// Current colour is custom: show it, marked active.
customSwatch.addClass('is-active');
customSwatch.style.backgroundColor = deriveFillColor(normCurrent);
customSwatch.style.borderColor = deriveBorderColor(normCurrent);
}
const colorInput = customSwatch.createEl('input', {
cls: 'wl-color-custom-input',
attr: { type: 'color' },
});
colorInput.value = normCurrent;
customSwatch.addEventListener('click', (e) => {
e.stopPropagation();
colorInput.click();
});
colorInput.addEventListener('change', (e) => {
e.stopPropagation();
const picked = normalizeHex(colorInput.value);
dismiss();
onPick(picked);
});
}
// Clear / "no colour" swatch — reverts the consumer to its default appearance.
const clearSwatch = palette.createEl('button', {
cls: 'wl-reading-col-palette-swatch wl-color-clear-swatch',
});
clearSwatch.title = 'No color (reset to default)';
clearSwatch.addEventListener('click', (e) => {
e.stopPropagation();
dismiss();
onPick(null);
});
window.setTimeout(() => doc.addEventListener('mousedown', close, true), 0);
}

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ import type WatchLogPlugin from './main';
import type { WatchLogData, WatchLogTitle, WatchLogGroup, AirtimeEntry, MaybeTitle, SavedFilterPreset, WatchLogPluginSettings, Book, Manga } from './types';
import type { HistoryManager } from './HistoryManager';
import { formatHistoryDate } from './HistoryManager';
import { getDisplayPoster, getDisplayTrailer, getDisplayCredits } from './types';
function isValidWatchLogData(data: unknown): data is WatchLogData {
return (
@ -104,6 +105,8 @@ export class DataManager {
if (title.posterUrl === undefined) { title.posterUrl = ''; changed = true; }
if (title.manualPosterUrl === undefined) { title.manualPosterUrl = ''; changed = true; }
if (title.trailerUrl === undefined) { title.trailerUrl = ''; changed = true; }
if (title.manualTrailerUrl === undefined) { title.manualTrailerUrl = ''; changed = true; }
if (title.anilistId === undefined) { title.anilistId = 0; changed = true; }
if (title.communityRating === undefined) { title.communityRating = 0; changed = true; }
if (title.communityVotes === undefined) { title.communityVotes = 0; changed = true; }
@ -156,6 +159,33 @@ export class DataManager {
void this.saveOnly();
}
/**
* Silent trailer URL update (manual refresh). Writes the value in memory and
* saves to disk without notifying listeners the caller re-renders its own row.
*/
updateTrailerUrl(titleId: string, url: string): void {
const title = this.data.titles.find((t) => t.id === titleId);
if (!title) return;
title.trailerUrl = url;
void this.saveOnly();
}
/**
* Persists a director/cast backfill through the normal update path: data.json
* AND the title's markdown frontmatter are written in the same operation (NOT
* the poster-style silent save, which skips the .md file). Deliberately does
* not stamp dateModified a background backfill is not a user edit and must
* not reshuffle "recently modified" sorts.
*/
async updateCredits(titleId: string, director: string[], cast: string[]): Promise<void> {
const title = this.getTitle(titleId);
if (!title) return;
title.director = director;
title.cast = cast;
await this.save();
await this.updateMarkdownFile(title);
}
private schedulePosterSave(): void {
if (this.posterSaveTimer !== null) return;
this.posterSaveTimer = window.setTimeout(() => {
@ -300,6 +330,11 @@ export class DataManager {
}
async addTitle(title: WatchLogTitle): Promise<void> {
// Ensure a freshly-added title carries an empty-string posterUrl (not undefined)
// so the Cards "needs-poster" gate enqueues an auto-fetch on first render.
if (title.posterUrl === undefined) title.posterUrl = '';
if (title.trailerUrl === undefined) title.trailerUrl = '';
if (title.manualTrailerUrl === undefined) title.manualTrailerUrl = '';
const autoUpcoming = this.canAutoAddToUpcoming(title);
if (autoUpcoming) {
title.status = 'To be released';
@ -1101,13 +1136,89 @@ export class DataManager {
const existing = this.app.vault.getAbstractFileByPath(filePath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
// Body-preserving update: swap the leading frontmatter block, then
// replace ONLY the `## Notes` section body with the title's notes.
// Everything else — frontmatter aside, other headings, any prose the
// user wrote outside `## Notes` — is kept verbatim (no append/dup).
// Mirrors how Reading rewrites frontmatter while preserving its body.
const current = await this.app.vault.read(existing);
let updated = this.rebuildWithFrontmatter(current, this.buildFrontmatter(title, progress));
updated = this.replaceNotesSection(updated, title.notes);
if (updated !== current) {
await this.app.vault.modify(existing, updated);
}
} else {
// New file: write the full template (frontmatter + ## Notes from title.notes).
await this.app.vault.create(filePath, content);
}
this.lastMarkdownPathById.set(title.id, filePath);
}
/**
* Replaces only the leading `---…---` frontmatter block, preserving the body.
* If the file has no frontmatter, the new block is prepended. Mirrors the
* Reading note path (ReadingDataManager.rebuildWithFrontmatter).
*/
private rebuildWithFrontmatter(existing: string, frontmatter: string): string {
const fmMatch = existing.match(/^---\n[\s\S]*?\n---\n?/);
if (fmMatch) {
return frontmatter + '\n' + existing.slice(fmMatch[0].length);
}
// No frontmatter found — prepend it.
return frontmatter + '\n\n' + existing;
}
/**
* Reads back the `## Notes` section body from an existing title note file.
* Returns null when the file or section is absent. Lets manual edits made
* directly in the .md file flow back into the plugin (mirrors how Reading
* reads its Quotes section from the file).
*/
async readNotesFromFile(title: WatchLogTitle): Promise<string | null> {
const filePath = this.getNoteFilePath(title);
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) {
// Diagnostic: the resolved path didn't match a real file on disk — the
// likely culprit if read-back ever stops working (sanitization / folder
// / rename mismatch). Logged so it can be verified against the vault.
console.warn(
`[WatchLog] Notes read: no note file at resolved path "${filePath}" for title "${title.title}"`,
);
return null;
}
const content = await this.app.vault.read(file);
const match = content.match(/(^|\n)## Notes[ \t]*\r?\n([\s\S]*?)(?=\n## |\n# |$)/);
if (!match) return null;
// Strip the leading blank line the template inserts and any trailing whitespace,
// but keep the user's internal line breaks intact.
return (match[2] ?? '').replace(/^\n+/, '').replace(/\s+$/, '');
}
/**
* Replaces the body of the `## Notes` section with `notesBody`, preserving the
* heading, every other section, the frontmatter, and any prose outside it. When
* no `## Notes` heading exists, one is appended at the end (never duplicated).
* The section boundary matches `readNotesFromFile` (next `## ` / `# ` heading or
* EOF) so a read-then-write round-trips cleanly.
*/
private replaceNotesSection(existing: string, notesBody: string): string {
const headingMatch = existing.match(/(^|\n)## Notes[ \t]*\r?\n/);
if (!headingMatch || headingMatch.index === undefined) {
const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
return `${existing}${sep}\n## Notes\n\n${notesBody}\n`;
}
const bodyStart = headingMatch.index + headingMatch[0].length;
const after = existing.slice(bodyStart);
const nextHeading = after.match(/\n(?:## |# )/);
const bodyEnd =
nextHeading && nextHeading.index !== undefined
? bodyStart + nextHeading.index
: existing.length;
const before = existing.slice(0, bodyStart);
const rest = existing.slice(bodyEnd);
return `${before}\n${notesBody}\n${rest}`;
}
async createMarkdownFileIfMissing(title: WatchLogTitle): Promise<boolean> {
const root = this.plugin.settings.rootFolder;
const folderPath = normalizePath(`${root}/${title.type}`);
@ -1120,19 +1231,80 @@ export class DataManager {
return true;
}
/**
* Rewrites ONLY the frontmatter block of an existing title note, preserving the
* entire body verbatim (`## Notes` and anything else). Does NOT create the file
* when missing returns false in that case. Powers the bulk "Rewrite all
* frontmatter" action that applies newer frontmatter fields to old notes.
*/
async rewriteFrontmatterIfExists(title: WatchLogTitle): Promise<boolean> {
const filePath = this.getNoteFilePath(title);
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return false;
const current = await this.app.vault.read(file);
const updated = this.rebuildWithFrontmatter(current, this.buildFrontmatter(title, this.getProgress(title)));
if (updated !== current) {
await this.app.vault.modify(file, updated);
}
this.lastMarkdownPathById.set(title.id, filePath);
return true;
}
/**
* YAML-quotes a string value (escaping backslashes and double quotes) so a
* title or URL containing `:` can't break the frontmatter. Same helper Reading
* uses; empty strings become `""`.
*/
private yamlEscape(value: string): string {
if (value === '') return '""';
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
/** The leading `---…---` frontmatter block (no trailing newline). */
private buildFrontmatter(title: WatchLogTitle, progress: number): string {
const esc = (v: string): string => this.yamlEscape(v);
const lines: string[] = ['---'];
// Text values are routed through yamlEscape; numbers/booleans/dates emit
// unquoted. Optional fields are omitted when absent to avoid noise.
lines.push(`title: ${esc(title.title)}`);
lines.push(`type: ${esc(title.type)}`);
lines.push(`status: ${esc(title.status)}`);
lines.push(`priority: ${esc(title.priority)}`);
if (title.review) lines.push(`review: ${esc(title.review)}`);
lines.push(`rating: ${title.rating}`);
lines.push(`progress: ${progress}%`);
lines.push(`totalEpisodes: ${title.totalEpisodes}`);
lines.push(`episodeDuration: ${title.episodeDuration}`);
lines.push(`dateStarted: ${title.dateStarted ?? 'null'}`);
lines.push(`dateFinished: ${title.dateFinished ?? 'null'}`);
lines.push(`dateAdded: ${esc(title.dateAdded)}`);
lines.push(`dateModified: ${esc(title.dateModified)}`);
if (title.releaseDate) lines.push(`releaseDate: ${title.releaseDate}`);
if (title.malId !== undefined) lines.push(`malId: ${title.malId}`);
if (title.anilistId !== undefined) lines.push(`anilistId: ${title.anilistId}`);
if (title.communityRating !== undefined) lines.push(`communityRating: ${title.communityRating}`);
if (title.communityVotes !== undefined) lines.push(`communityVotes: ${title.communityVotes}`);
if (title.communitySource) lines.push(`communitySource: ${esc(title.communitySource)}`);
// Inline YAML arrays; unfetched and 'none' credits are both omitted.
const director = getDisplayCredits(title.director);
if (director.length > 0) lines.push(`director: [${director.map(esc).join(', ')}]`);
const cast = getDisplayCredits(title.cast);
if (cast.length > 0) lines.push(`cast: [${cast.map(esc).join(', ')}]`);
// Single resolved poster (manual override if set, else auto-fetched).
const poster = getDisplayPoster(title);
if (poster && poster !== 'none') lines.push(`poster: ${esc(poster)}`);
// Single resolved trailer (manual override if set, else auto-fetched).
const trailer = getDisplayTrailer(title);
if (trailer) lines.push(`trailer: ${esc(trailer)}`);
if (title.externalLink) lines.push(`externalLink: ${esc(title.externalLink)}`);
if (title.pinned) lines.push(`pinned: true`);
lines.push('---');
return lines.join('\n');
}
/** Full note template (frontmatter + ## Notes body). Used only when creating a new file. */
private buildMarkdownContent(title: WatchLogTitle, progress: number): string {
return `---
title: ${title.title.replace(/[*"\\/<>:|?]/g, '-')}
type: ${title.type}
status: ${title.status}
priority: ${title.priority}
rating: ${title.rating}
dateStarted: ${title.dateStarted ?? 'null'}
dateFinished: ${title.dateFinished ?? 'null'}
progress: ${progress}%
totalEpisodes: ${title.totalEpisodes}
externalLink: ${title.externalLink}
---
return `${this.buildFrontmatter(title, progress)}
## Notes

View file

@ -1,7 +1,7 @@
import { App, Modal, Notice } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { WatchLogTitle, Season } from './types';
import type { WatchLogTitle, WatchLogGroup, Season } from './types';
import { formatDateDisplay, parseDateInput, parseReleaseDateInput } from './types';
/** Parses "5,8,33-37,42" into a sorted unique flat array of numbers. */
@ -92,8 +92,16 @@ export class EditTitleModal extends Modal {
private fieldDateStarted: string;
private fieldDateFinished: string;
private fieldPosterUrl: string;
private fieldTrailerUrl: string;
// Group assignment (mirrors AddTitleModal). `originalGroupId` records the group
// the title belonged to on open, so save can move/detach it only when changed.
private originalGroupId = '';
private selectedGroupId = '';
private newGroupName = '';
private skipDuplicateCheck = false;
private duplicateWarningEl: HTMLElement | null = null;
private notesInput: HTMLTextAreaElement | null = null;
private notesEditedByUser = false;
constructor(
app: App,
@ -124,12 +132,28 @@ export class EditTitleModal extends Modal {
this.fieldDateStarted = title.dateStarted ?? '';
this.fieldDateFinished = title.dateFinished ?? '';
this.fieldPosterUrl = title.manualPosterUrl ?? '';
this.fieldTrailerUrl = title.manualTrailerUrl ?? '';
// Seed the group dropdown with the title's current group (if any).
this.originalGroupId =
this.dataManager.getGroups().find((g) => g.titleIds.includes(title.id))?.id ?? '';
this.selectedGroupId = this.originalGroupId;
}
onOpen(): void {
this.titleEl.setText(`Edit: ${this.original.title}`);
this.contentEl.addClass('wl-add-modal');
this.buildUI();
// Pull the ## Notes body back from the .md file so manual edits made directly
// in the file are reflected in the modal (mirrors Reading reading Quotes back).
void this.syncNotesFromFile();
}
/** Reflects the on-disk ## Notes body into the notes field, unless the user is already editing it. */
private async syncNotesFromFile(): Promise<void> {
const fileNotes = await this.dataManager.readNotesFromFile(this.original);
if (fileNotes === null || this.notesEditedByUser) return;
this.fieldNotes = fileNotes;
if (this.notesInput) this.notesInput.value = fileNotes;
}
onClose(): void {
@ -146,6 +170,17 @@ export class EditTitleModal extends Modal {
return row;
};
// A paired-row cell: [label][fixed-width input]. Mirrors AddTitleModal so two
// labelled fields share one row (left cell aligns with full-width inputs, the
// right cell anchors to the row's right edge). See .wl-modal-pair-cell CSS.
const makeCell = (row: HTMLElement, label: string, right = false): HTMLElement => {
const cell = row.createDiv({
cls: right ? 'wl-modal-pair-cell wl-modal-pair-cell-right' : 'wl-modal-pair-cell',
});
cell.createSpan({ cls: 'wl-modal-label', text: label });
return cell;
};
// Title
const titleRow = makeRow('Title');
const titleInput = titleRow.createEl('input', {
@ -164,9 +199,11 @@ export class EditTitleModal extends Modal {
}
typeSelect.addEventListener('change', () => { this.fieldType = typeSelect.value; });
// Status — "To be released" is auto-assigned; show it only if title currently has it
const statusRow = makeRow('Status');
const statusSelect = statusRow.createEl('select', { cls: 'wl-select' });
// Status + Priority paired 50/50 on one row.
// ("To be released" is auto-assigned; show it only if the title currently has it.)
const statusPriRow = content.createDiv({ cls: 'wl-modal-row wl-modal-row-pair' });
const statusCell = makeCell(statusPriRow, 'Status');
const statusSelect = statusCell.createEl('select', { cls: 'wl-select' });
const showToBeReleased = this.fieldStatus === 'To be released';
for (const s of this.plugin.settings.statuses) {
if (s.name === 'To be released' && !showToBeReleased) continue;
@ -175,9 +212,8 @@ export class EditTitleModal extends Modal {
}
statusSelect.addEventListener('change', () => { this.fieldStatus = statusSelect.value; });
// Priority
const priRow = makeRow('Priority');
const priSelect = priRow.createEl('select', { cls: 'wl-select' });
const priCell = makeCell(statusPriRow, 'Priority', true);
const priSelect = priCell.createEl('select', { cls: 'wl-select' });
for (const p of this.plugin.settings.priorities) {
const opt = priSelect.createEl('option', { text: p.name, value: p.name });
if (p.name === this.fieldPriority) opt.selected = true;
@ -202,15 +238,17 @@ export class EditTitleModal extends Modal {
};
updateStarDisplay();
// Episodes
const epsRow = makeRow('Total episodes');
const epsInput = epsRow.createEl('input', {
cls: 'wl-modal-input wl-modal-input-sm',
// Total episodes + Ep. duration paired on one row (episodes keeps its seasons
// skip/watch inline labels and behaviour untouched — only the layout changed).
const epsDurRow = content.createDiv({ cls: 'wl-modal-row wl-modal-row-pair' });
const epsCell = makeCell(epsDurRow, 'Total episodes');
const epsInput = epsCell.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'number', min: '0' },
});
epsInput.value = String(this.fieldEpisodes);
const epsSkipLabel = epsRow.createSpan({ cls: 'wl-modal-skip-inline' });
const epsWatchLabel = epsRow.createSpan({ cls: 'wl-modal-skip-inline' });
const epsSkipLabel = epsCell.createSpan({ cls: 'wl-modal-skip-inline' });
const epsWatchLabel = epsCell.createSpan({ cls: 'wl-modal-skip-inline' });
const updateSkipCounts = (): void => {
const parsed = parseSeasonsText(this.fieldSeasonsText);
@ -230,10 +268,9 @@ export class EditTitleModal extends Modal {
updateSkipCounts();
});
// Duration
const durRow = makeRow('Ep. duration (min)');
const durInput = durRow.createEl('input', {
cls: 'wl-modal-input wl-modal-input-sm',
const durCell = makeCell(epsDurRow, 'Ep. duration (min)', true);
const durInput = durCell.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'number', min: '0' },
});
durInput.value = String(this.fieldDuration);
@ -290,9 +327,24 @@ export class EditTitleModal extends Modal {
text: 'Override the auto-fetched cover. Leave blank to let WatchLog fetch one again.',
});
// Date started
const startRow = makeRow('Date started');
const startInput = startRow.createEl('input', {
// Trailer URL (manual override) — directly under Poster URL
const trailerRow = makeRow('Trailer URL');
const trailerStack = trailerRow.createDiv({ cls: 'wl-modal-input-stack' });
const trailerInput = trailerStack.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'url', placeholder: 'https://www.youtube.com/watch?v=...' },
});
trailerInput.value = this.fieldTrailerUrl;
trailerInput.addEventListener('input', () => { this.fieldTrailerUrl = trailerInput.value; });
trailerStack.createDiv({
cls: 'wl-modal-info',
text: 'Override the auto-fetched trailer. Leave blank to use the fetched one.',
});
// Date started + Date finished paired on one row.
const datesRow = content.createDiv({ cls: 'wl-modal-row wl-modal-row-pair' });
const startCell = makeCell(datesRow, 'Date started');
const startInput = startCell.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' },
});
@ -307,9 +359,8 @@ export class EditTitleModal extends Modal {
}
});
// Date finished
const finRow = makeRow('Date finished');
const finInput = finRow.createEl('input', {
const finCell = makeCell(datesRow, 'Date finished', true);
const finInput = finCell.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' },
});
@ -324,6 +375,49 @@ export class EditTitleModal extends Modal {
}
});
// Group assignment (move / create) — ported from AddTitleModal, seeded with
// the title's current group. Dropdown and new-name input disable each other.
const groups = this.dataManager.getGroups();
const addToGroupRow = makeRow('Add to group');
const groupSelect = addToGroupRow.createEl('select', { cls: 'wl-select' });
groupSelect.createEl('option', { text: '— none —', value: '' });
for (const g of groups) {
const opt = groupSelect.createEl('option', { text: g.name, value: g.id });
if (g.id === this.selectedGroupId) opt.selected = true;
}
const newGroupRow = makeRow('Or create new group');
const newGroupInput = newGroupRow.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'text', placeholder: 'New group name...' },
});
newGroupInput.value = this.newGroupName;
groupSelect.addEventListener('change', () => {
this.selectedGroupId = groupSelect.value;
if (this.selectedGroupId) {
this.newGroupName = '';
newGroupInput.value = '';
newGroupInput.disabled = true;
} else {
newGroupInput.disabled = false;
}
});
newGroupInput.addEventListener('input', () => {
this.newGroupName = newGroupInput.value;
if (this.newGroupName.trim()) {
this.selectedGroupId = '';
groupSelect.value = '';
groupSelect.disabled = true;
} else {
groupSelect.disabled = false;
}
});
if (this.selectedGroupId) newGroupInput.disabled = true;
if (this.newGroupName.trim()) groupSelect.disabled = true;
// Notes
const notesRow = makeRow('Notes');
const notesInput = notesRow.createEl('textarea', {
@ -331,7 +425,11 @@ export class EditTitleModal extends Modal {
attr: { rows: '3', placeholder: 'Your notes...' },
});
notesInput.value = this.fieldNotes;
notesInput.addEventListener('input', () => { this.fieldNotes = notesInput.value; });
this.notesInput = notesInput;
notesInput.addEventListener('input', () => {
this.notesEditedByUser = true;
this.fieldNotes = notesInput.value;
});
// Seasons
const seasonsRow = makeRow('Seasons');
@ -440,6 +538,7 @@ export class EditTitleModal extends Modal {
dateStarted: this.fieldDateStarted || null,
dateFinished: this.fieldDateFinished || null,
manualPosterUrl: this.fieldPosterUrl.trim(),
manualTrailerUrl: this.fieldTrailerUrl.trim(),
};
// Auto-mark all episodes watched when status is set to Completed
@ -496,6 +595,33 @@ export class EditTitleModal extends Modal {
await this.dataManager.removeAirtimeEntriesForTitle(updated.id);
}
// ── Group membership (move / create), mirroring AddTitleModal ────────────────
// Only act when the selection changed relative to the title's original group.
const newGroupName = this.newGroupName.trim();
const groupChanged = newGroupName !== '' || this.selectedGroupId !== this.originalGroupId;
if (groupChanged) {
// Detach from the previous group first.
if (this.originalGroupId) {
const old = this.dataManager.getGroup(this.originalGroupId);
if (old) {
old.titleIds = old.titleIds.filter((id) => id !== updated.id);
await this.dataManager.updateGroup(old);
}
}
if (newGroupName) {
const group: WatchLogGroup = {
id: this.dataManager.generateGroupId(newGroupName),
name: newGroupName,
titleIds: [updated.id],
dateAdded: new Date().toISOString(),
};
await this.dataManager.addGroup(group);
} else if (this.selectedGroupId) {
await this.dataManager.addTitleToGroup(this.selectedGroupId, updated.id);
}
// else: moved to "— none —" — detached only.
}
new Notice(`"${titleName}" updated.`);
this.close();
this.onSaved();

View file

@ -1,8 +1,9 @@
import { Modal } from 'obsidian';
import Fuse from 'fuse.js';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { WatchLogTitle, WatchLogGroup, TagDefinition, SavedFilterPreset, Season } from './types';
import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDisplayPoster } from './types';
import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDisplayPoster, getDisplayCredits } from './types';
import { renderCommunityRating, maybeAutoRefreshCommunityRating, refreshCommunityRating } from './CommunityRating';
import { AddTitleModal } from './AddTitleModal';
import { AddFromUrlModal } from './AddFromUrlModal';
@ -10,7 +11,7 @@ import { AddTitleChoiceModal } from './AddTitleChoiceModal';
import { renderToolbarWithMobileToggle } from './MobileToolbar';
import { EditTitleModal } from './EditTitleModal';
import { ConfirmModal } from './ConfirmModal';
import { TitleDetailModal, GroupDetailModal } from './TitleDetailModal';
import { TitleDetailModal, GroupDetailModal, renderCreditsRows } from './TitleDetailModal';
import { renderGroupCollage } from './CollageUtil';
// ── Display item type ─────────────────────────────────────────────────────────
@ -91,6 +92,14 @@ export class ListTab {
private cardsResizeObserver: ResizeObserver | null = null;
private cardsLastFirst = -1;
private cardsLastLast = -1;
// Last-applied layout-write inputs. Used to skip the spacer/transform/CSS-var
// writes when neither the scroll offset nor the grid geometry changed, so a
// same-window scroll tick doesn't dirty layout (which would force a reflow on
// the next clientHeight read).
private cardsLastFirstRow = -1;
private cardsLastCols = -1;
private cardsLastCardHeight = -1;
private cardsLastTotalRows = -1;
private cardsLastScrollTop = 0;
// Survives destroyVirtualScroll() so render() can restore scroll position
// after a full Cards-view rebuild (mirrors how _lastScrollTop works for List).
@ -106,6 +115,11 @@ export class ListTab {
// Cleaned up on rerender so DOM removal doesn't leave dangling listeners.
private activeCleanups: (() => void)[] = [];
// Toolbar action-button wrapper (display: contents) — lets selection-state
// changes rebuild just the buttons without touching the search input or the
// tab content DOM.
private toolbarActionsEl: HTMLElement | null = null;
constructor(container: HTMLElement, plugin: WatchLogPlugin, dataManager: DataManager) {
this.container = container;
this.plugin = plugin;
@ -328,8 +342,13 @@ export class ListTab {
this.cardsScrollContainer = scroll;
this.cardsScrollSpacer = spacer;
this.cardsGridEl = grid;
this.cardsLastFirst = -1;
this.cardsLastLast = -1;
this.cardsLastFirstRow = -1;
this.cardsLastCols = -1;
this.cardsLastCardHeight = -1;
this.cardsLastTotalRows = -1;
this.cardsScrollHandler = () => {
const scrollTop = scroll.scrollTop;
@ -353,8 +372,16 @@ export class ListTab {
// the spacer height is set) — the CSS grid reflows on its own and the
// internal range comparison already short-circuits if no items moved.
// Resetting here caused a spurious second "initial" render.
// rAF-guard like the scroll handler — a resize can fire a burst of
// callbacks (e.g. scrollbar appear/disappear), and calling
// renderVisibleCards synchronously each time bounces layout. Coalesce
// into one render per frame via the shared cardsScrollRAF token.
this.cardsResizeObserver = new ResizeObserver(() => {
this.renderVisibleCards();
if (this.cardsScrollRAF !== null) return;
this.cardsScrollRAF = window.requestAnimationFrame(() => {
this.cardsScrollRAF = null;
this.renderVisibleCards();
});
});
this.cardsResizeObserver.observe(scroll);
@ -409,14 +436,46 @@ export class ListTab {
const firstIndex = firstVisibleRow * cols;
const lastIndex = Math.min(totalItems - 1, (lastVisibleRow + 1) * cols - 1);
spacer.style.height = `${totalRows * rowHeight}px`;
grid.style.transform = `translateY(${firstVisibleRow * rowHeight}px)`;
grid.style.setProperty('--wl-cards-cols', String(cols));
grid.style.setProperty('--wl-card-height', `${cardHeight}px`);
const oldFirst = this.cardsLastFirst;
const oldLast = this.cardsLastLast;
if (firstIndex === oldFirst && lastIndex === oldLast) return;
const windowUnchanged = firstIndex === oldFirst && lastIndex === oldLast;
// The four layout writes below only depend on these inputs (rowHeight is
// cardHeight + a fixed gap, so cardHeight covers it). When none changed,
// the writes would be no-ops — but still dirty layout — so skip them.
const layoutUnchanged =
firstVisibleRow === this.cardsLastFirstRow &&
cols === this.cardsLastCols &&
cardHeight === this.cardsLastCardHeight &&
totalRows === this.cardsLastTotalRows;
// Same visible window and same geometry — nothing to do. Bail before any
// DOM write so a scroll within the current row window stays free.
if (windowUnchanged && layoutUnchanged) return;
if (!layoutUnchanged) {
spacer.style.height = `${totalRows * rowHeight}px`;
// Offset the grid with padding-top, NOT transform: translateY. With a
// transform, every row advance (remove top row + append bottom row +
// transform bump in one frame) makes Chromium's layout-shift tracker
// report one full row of stationary cards as shifted (previousRect
// 0×0 → poster/collage rect) — the CLS that climbed on every scroll.
// Padding replaces the removed row in layout, so each surviving
// card's layout rect is literally unchanged and nothing is recorded.
// (Verified in a headless-Chromium harness: transform ≈ 0.14 CLS per
// row advance; padding-top = 0. ReadingTab avoids it differently, by
// rebuilding the whole window — inserted-only content is never
// counted as a shift.)
grid.style.paddingTop = `${firstVisibleRow * rowHeight}px`;
grid.style.setProperty('--wl-cards-cols', String(cols));
grid.style.setProperty('--wl-card-height', `${cardHeight}px`);
this.cardsLastFirstRow = firstVisibleRow;
this.cardsLastCols = cols;
this.cardsLastCardHeight = cardHeight;
this.cardsLastTotalRows = totalRows;
}
if (windowUnchanged) return;
const isInitial = oldFirst === -1 || oldLast === -1;
const hasOverlap = !isInitial && firstIndex <= oldLast && lastIndex >= oldFirst;
@ -484,6 +543,7 @@ export class ListTab {
if (card) fragment.appendChild(card);
}
grid.appendChild(fragment);
}
private buildCardElement(
@ -545,9 +605,11 @@ export class ListTab {
const title = this.dataManager.getTitles().find((t) => t.id === titleId);
if (!title) continue;
// Manual override takes priority — skip auto-fetch for manual overrides
// and for titles whose auto-fetched poster is already populated.
// and for titles whose poster is already resolved (an http URL, or the
// 'none' sentinel meaning "tried, nothing found — don't refetch").
if (title.manualPosterUrl && title.manualPosterUrl.trim() !== '') continue;
if (title.posterUrl !== '') continue;
// Needs a poster only when missing (undefined / null / "") and not 'none'.
if (title.posterUrl || title.posterUrl === 'none') continue;
const placeholder = card.querySelector('.wl-card-poster-placeholder');
placeholder?.addClass('is-loading');
@ -604,6 +666,10 @@ export class ListTab {
this.cardsDisplayItems = [];
this.cardsLastFirst = -1;
this.cardsLastLast = -1;
this.cardsLastFirstRow = -1;
this.cardsLastCols = -1;
this.cardsLastCardHeight = -1;
this.cardsLastTotalRows = -1;
this.cardsLastScrollTop = 0;
this.cardsRowHeight = 0;
this.destroyCardsObserver();
@ -623,6 +689,8 @@ export class ListTab {
const card = parent.createDiv({ cls: 'wl-card' });
card.dataset.titleId = title.id;
// Cards built while scrolling during selection mode must show their state
if (this.selectedItems.has(title.id)) card.addClass('wl-card-selected');
// Width is distributed by the parent CSS grid (1fr columns) — do not set
// an explicit width here. Setting fractional pixel widths per card caused
// flex-wrap to overflow and shift the grid left by one slot per scroll.
@ -635,18 +703,36 @@ export class ListTab {
const img = card.createEl('img', { cls: 'wl-card-poster' });
img.alt = title.title;
// Give the <img> definite intrinsic dimensions so it never momentarily
// lays out at its natural (post-load) size; the CSS (absolute inset:0 +
// object-fit:cover) then makes it fill the card's box. (The scroll-CLS
// logged against this img was the grid's translateY offset, not the
// image load — see renderVisibleCards.)
if (cardWidth !== undefined && cardHeight !== undefined) {
img.width = Math.round(cardWidth);
img.height = Math.round(cardHeight);
}
const display = getDisplayPoster(title);
const isManual = !!(title.manualPosterUrl && title.manualPosterUrl.trim() !== '');
// A poster is still needed when it's missing (undefined / null / "") — but NOT
// when it's the 'none' sentinel (already tried, nothing found — don't refetch).
const needsPoster = !isManual && !title.posterUrl && title.posterUrl !== 'none';
if (display && display.startsWith('http')) {
img.src = display;
card.addClass('has-poster');
// Reveal synchronously: gating on onload made every re-render flash the
// letter placeholder for a frame, because onload is async even for a
// fully cached image. An <img> paints nothing until its data arrives,
// so the placeholder behind it still covers a genuinely slow load;
// onerror re-hides the img (broken-image glyph) and restores the
// placeholder. Attach onerror before src so a cached failure still fires.
img.onerror = () => {
card.removeClass('has-poster');
// Only mark the auto-fetched URL as 'none' — don't touch a user's manual choice.
if (!isManual) this.dataManager.updatePosterUrl(title.id, 'none');
};
} else if (!isManual && title.posterUrl === '') {
card.addClass('has-poster');
img.src = display;
} else if (needsPoster) {
card.dataset.needsPoster = 'true';
}
@ -693,6 +779,10 @@ export class ListTab {
});
card.addEventListener('click', () => {
if (this.selectionMode) {
this.toggleCardSelection(card, title.id);
return;
}
this.openDetailModalForTitle(title.id);
});
}
@ -796,6 +886,8 @@ export class ListTab {
: '#888780';
const card = parent.createDiv({ cls: 'wl-card wl-card-group' });
card.dataset.groupId = group.id;
if (this.selectedItems.has(group.id)) card.addClass('wl-card-selected');
if (cardHeight !== undefined) card.style.height = `${cardHeight}px`;
const letter = (group.name.trim().charAt(0) || '?').toUpperCase();
@ -818,9 +910,13 @@ export class ListTab {
});
card.addEventListener('click', () => {
if (this.selectionMode) {
this.toggleCardSelection(card, group.id);
return;
}
new GroupDetailModal(this.plugin.app, this.plugin, group, members, () => {
this.render();
}).open();
}, (name) => this.applyPersonSearch(name)).open();
});
}
@ -926,7 +1022,17 @@ export class ListTab {
if (!current) return;
new TitleDetailModal(this.plugin.app, this.plugin, current, () => {
this.render();
}).open();
}, (name) => this.applyPersonSearch(name)).open();
}
/**
* Click-to-filter for a director/actor name: routes the name through the
* existing search bar (which matches director/cast), so the filter is
* visible, clearable, and scoped to this view exactly like a typed search.
*/
private applyPersonSearch(name: string): void {
this.searchQuery = name;
this.render();
}
// ── Header ────────────────────────────────────────────────────────────────────
@ -951,12 +1057,82 @@ export class ListTab {
renderToolbarWithMobileToggle({
controls,
renderSearch: (parent) => this.renderSearch(parent),
renderActions: (parent) => this.renderToolbarActions(parent),
renderActions: (parent) => {
// display: contents wrapper — children still participate in the
// controls flex row, but the buttons can be rebuilt in isolation.
const wrap = parent.createDiv({ cls: 'wl-toolbar-actions' });
this.toolbarActionsEl = wrap;
this.renderToolbarActions(wrap);
},
expanded: this.toolbarExpanded,
onToggleChange: (expanded) => { this.toolbarExpanded = expanded; },
});
}
/**
* Rebuilds only the toolbar action buttons (Select / All / action bar / ).
* Used by selection-state changes so the tab content especially the Cards
* virtual-scroll grid is never torn down just to update the header.
*/
private refreshToolbarActions(): void {
const wrap = this.toolbarActionsEl;
if (!wrap || !wrap.isConnected) return;
wrap.empty();
this.renderToolbarActions(wrap);
}
/**
* Enters/exits selection mode without a full tab rebuild. Cards view only
* needs its selection borders cleared (the grid DOM is untouched); List view
* rebuilds just the table because rows gain/lose the checkbox column.
*/
private setSelectionMode(on: boolean): void {
this.selectionMode = on;
this.selectedItems.clear();
this.refreshToolbarActions();
if (this.currentSubTab === 'cards') {
this.syncCardSelectionClasses();
} else {
this.rerenderTable();
}
}
/** Applies/removes wl-card-selected on every currently rendered card. */
private syncCardSelectionClasses(): void {
const grid = this.cardsGridEl;
if (!grid) return;
grid.querySelectorAll<HTMLElement>('.wl-card').forEach((card) => {
const id = card.dataset.titleId ?? card.dataset.groupId;
card.classList.toggle('wl-card-selected', !!id && this.selectedItems.has(id));
});
}
/** Card click in selection mode — pure state + class toggle, no re-render. */
private toggleCardSelection(card: HTMLElement, id: string): void {
if (this.selectedItems.has(id)) {
this.selectedItems.delete(id);
} else {
this.selectedItems.add(id);
}
card.classList.toggle('wl-card-selected', this.selectedItems.has(id));
this.refreshToolbarActions();
}
/** Row click/checkbox in List selection mode — class toggle, no re-render. */
private toggleRowSelection(row: HTMLElement, id: string): boolean {
const nowSelected = !this.selectedItems.has(id);
if (nowSelected) {
this.selectedItems.add(id);
} else {
this.selectedItems.delete(id);
}
row.classList.toggle('wl-row-selected', nowSelected);
const cb = row.querySelector<HTMLInputElement>('.wl-col-select input');
if (cb) cb.checked = nowSelected;
this.refreshToolbarActions();
return nowSelected;
}
/**
* Renders the toolbar action buttons (saved filter, filters, sorting,
* selection, and the two add buttons). On desktop these sit inline after the
@ -974,13 +1150,11 @@ export class ListTab {
savedBtn.addEventListener('click', () => {
this.applyPreset(preset);
this.savedFilterActive = true;
savedBtn.addClass('wl-btn-preset-active');
this.saveFiltersToSettings();
// Full render so the filter bar (Clear button, filter dot) is
// rebuilt with the new active-filter state on whichever sub-tab
// is current — rerenderTable/rerenderActiveSubTab only refresh
// the content area, not the header.
this.render();
// Rebuild the toolbar (Clear button, filter dot, preset-active
// state) plus the content area — no need to nuke the whole tab.
this.refreshToolbarActions();
this.rerenderActiveSubTab();
});
} else {
this.savedFilterBtnEl = null;
@ -1010,7 +1184,12 @@ export class ListTab {
this.selectedItems.add(item.data.id);
}
}
this.render();
this.refreshToolbarActions();
if (this.currentSubTab === 'cards') {
this.syncCardSelectionClasses();
} else {
this.rerenderTable();
}
});
}
@ -1020,9 +1199,7 @@ export class ListTab {
text: 'Select',
});
selBtn.addEventListener('click', () => {
this.selectionMode = !this.selectionMode;
this.selectedItems.clear();
this.render();
this.setSelectionMode(!this.selectionMode);
});
// Add button pinned to the far right of the toolbar row — opens a chooser
@ -1391,7 +1568,8 @@ export class ListTab {
panel.remove();
activeDocument.removeEventListener('click', closer, true);
this.saveFiltersToSettings();
this.render();
// Sorting only reorders the content — the toolbar is unaffected.
this.rerenderActiveSubTab();
});
dirBtn.addEventListener('click', (ev) => {
@ -1404,7 +1582,7 @@ export class ListTab {
panel.remove();
activeDocument.removeEventListener('click', closer, true);
this.saveFiltersToSettings();
this.render();
this.rerenderActiveSubTab();
});
};
@ -1536,11 +1714,12 @@ export class ListTab {
this.render();
};
confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); void doCreate(); });
cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); this.render(); });
// Cancel only needs the toolbar back — nothing in the content changed.
cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); this.refreshToolbarActions(); });
nameInput.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') void doCreate();
if (e.key === 'Escape') this.render();
if (e.key === 'Escape') this.refreshToolbarActions();
});
nameInput.addEventListener('click', (e) => e.stopPropagation());
window.setTimeout(() => nameInput.focus(), 0);
@ -1907,12 +2086,14 @@ export class ListTab {
const allTitles = this.dataManager.getTitles();
const titleMap = new Map(allTitles.map((t) => [t.id, t]));
const q = this.searchQuery.trim().toLowerCase();
// One fuzzy pass shared by the top-level filter and the group member match.
const searchMatched = this.getSearchMatchedIds();
// Top-level titles (not in any group), filtered. Hidden entirely when
// "Groups only" is active.
const topLevel = this.filterGroupsOnly
? []
: this.getFilteredSortedTitles().filter((t) => !groupedIds.has(t.id));
: this.getFilteredSortedTitles(searchMatched).filter((t) => !groupedIds.has(t.id));
// Groups: skip those excluded by group filter; show if any member passes filters
const displayGroups: Array<{ kind: 'group'; data: WatchLogGroup; members: WatchLogTitle[] }> = [];
@ -1923,10 +2104,11 @@ export class ListTab {
.map((id) => titleMap.get(id))
.filter((t): t is WatchLogTitle => t !== undefined);
// Search: show group if name matches OR any member title matches
if (q) {
// Search: show group if name matches OR any member matches the fuzzy
// title/director/cast search.
if (searchMatched) {
const nameMatch = group.name.toLowerCase().includes(q);
const memberMatch = members.some((m) => m.title.toLowerCase().includes(q));
const memberMatch = members.some((m) => searchMatched.has(m.id));
if (!nameMatch && !memberMatch) continue;
}
@ -2112,9 +2294,7 @@ export class ListTab {
cb.checked = isSelected;
cb.addEventListener('click', (e) => e.stopPropagation());
cb.addEventListener('change', () => {
if (cb.checked) { this.selectedItems.add(title.id); }
else { this.selectedItems.delete(title.id); }
this.render();
this.toggleRowSelection(row, title.id);
});
}
@ -2195,9 +2375,7 @@ export class ListTab {
row.addEventListener('click', () => {
if (this.selectionMode) {
if (this.selectedItems.has(title.id)) { this.selectedItems.delete(title.id); }
else { this.selectedItems.add(title.id); }
this.render();
this.toggleRowSelection(row, title.id);
return;
}
this.expandedId = isExpanded ? null : title.id;
@ -2239,9 +2417,7 @@ export class ListTab {
cb.checked = isSelected;
cb.addEventListener('click', (e) => e.stopPropagation());
cb.addEventListener('change', () => {
if (cb.checked) { this.selectedItems.add(group.id); }
else { this.selectedItems.delete(group.id); }
this.render();
this.toggleRowSelection(row, group.id);
});
}
@ -2415,9 +2591,7 @@ export class ListTab {
row.addEventListener('click', () => {
if (this.renamingGroupId === group.id) return;
if (this.selectionMode) {
if (this.selectedItems.has(group.id)) { this.selectedItems.delete(group.id); }
else { this.selectedItems.add(group.id); }
this.render();
this.toggleRowSelection(row, group.id);
return;
}
if (isExpanded) {
@ -2503,6 +2677,11 @@ export class ListTab {
linkIcon.addEventListener('click', (e) => e.stopPropagation());
}
// Director / Cast rows under the status line; clicking a name filters
// this view via the search bar (same fallback rendering as the modal).
const creditsWrap = accLeft.createDiv({ cls: 'wl-acc-credits' });
renderCreditsRows(creditsWrap, title, (name) => this.applyPersonSearch(name));
// Stats row: 3 new blocks + existing progress block, all in one right-aligned row
const watchedEps = title.watchedEpisodes.length;
const effectiveTotal = this.dataManager.getEffectiveTotal(title);
@ -2921,12 +3100,34 @@ export class ListTab {
}
}
private getFilteredSortedTitles(): WatchLogTitle[] {
/**
* Fuzzy search (fuse.js) over title + director + cast names. Returns the
* matching title-id set, or null when the query is empty (no search filter).
* The 'none' sentinel and unfetched credits are excluded from matching.
*/
private getSearchMatchedIds(): Set<string> | null {
const q = this.searchQuery.trim();
if (!q) return null;
const records = this.dataManager.getTitles().map((t) => ({
id: t.id,
title: t.title,
director: getDisplayCredits(t.director),
cast: getDisplayCredits(t.cast),
}));
const fuse = new Fuse(records, {
keys: ['title', 'director', 'cast'],
threshold: 0.4,
ignoreLocation: true,
});
return new Set(fuse.search(q).map((r) => r.item.id));
}
private getFilteredSortedTitles(searchMatched?: Set<string> | null): WatchLogTitle[] {
let titles = this.dataManager.getTitles();
if (this.searchQuery.trim()) {
const q = this.searchQuery.toLowerCase();
titles = titles.filter((t) => t.title.toLowerCase().includes(q));
const matched = searchMatched !== undefined ? searchMatched : this.getSearchMatchedIds();
if (matched) {
titles = titles.filter((t) => matched.has(t.id));
}
titles = titles.filter((t) => this.titlePassesFilters(t));

View file

@ -48,6 +48,19 @@ interface TmdbMultiResult {
results?: Array<{ poster_path?: string | null }>;
}
interface TmdbPosterSearchResult {
results?: Array<{
poster_path?: string | null;
release_date?: string;
first_air_date?: string;
}>;
}
interface TmdbFindPosterResult {
movie_results?: Array<{ poster_path?: string | null }>;
tv_results?: Array<{ poster_path?: string | null }>;
}
interface OmdbResult {
Poster?: string;
Response?: string;
@ -195,22 +208,120 @@ export class PosterService {
return jpg?.large_image_url ?? jpg?.image_url ?? null;
}
/** Extracts an IMDb id (`tt…`) from an external link, or '' if none present. */
private extractImdbId(externalLink: string | undefined): string {
const m = (externalLink ?? '').match(/tt\d+/);
return m ? m[0] : '';
}
/** The 4-digit year from the entry's stored releaseDate, or null if unusable. */
private getReleaseYear(title: WatchLogTitle): number | null {
const m = (title.releaseDate ?? '').match(/^(\d{4})/);
return m && m[1] ? parseInt(m[1], 10) : null;
}
/**
* Resolves whether a title should be searched as a movie or a TV series.
* Explicit Movie / TV Show types win; otherwise (custom type) infer from
* totalEpisodes (1 movie, >1 series). Returns null when there's no signal
* (missing/0 episodes) so the caller can fall back to the legacy multi-search.
*/
private resolveMediaKind(title: WatchLogTitle): 'movie' | 'tv' | null {
if (title.type === 'Movie') return 'movie';
if (title.type === 'TV Show' || title.type === 'TvShow') return 'tv';
const eps = title.totalEpisodes;
if (eps === 1) return 'movie';
if (eps > 1) return 'tv';
return null;
}
/**
* Picks the best poster from TMDB search results: prefers the candidate whose
* release year matches the entry, then falls back to the first result that has
* a poster. Returns the raw poster_path (not the full URL) or null.
*/
private pickTmdbPoster(
results: TmdbPosterSearchResult['results'],
year: number | null,
): string | null {
if (!results || results.length === 0) return null;
if (year !== null) {
const yearStr = String(year);
const matched = results.find((r) => {
const date = r.release_date ?? r.first_air_date ?? '';
return date.startsWith(yearStr) && r.poster_path;
});
if (matched?.poster_path) return matched.poster_path;
}
const first = results.find((r) => r.poster_path);
return first?.poster_path ?? null;
}
private async fetchMediaPoster(title: WatchLogTitle): Promise<string | null> {
const settings = this.getSettings();
const searchName = this.cleanTitleForSearch(title.title);
const imdbId = this.extractImdbId(title.externalLink);
const year = this.getReleaseYear(title);
const kind = this.resolveMediaKind(title);
if (settings.tmdbApiKey) {
const headers = { Authorization: `Bearer ${settings.tmdbApiKey}` };
// 1. Most precise: resolve by IMDb id directly.
if (imdbId) {
const url = `${TMDB_BASE}/find/${encodeURIComponent(imdbId)}?external_source=imdb_id`;
const data = (await this.fetchJson(url, headers)) as TmdbFindPosterResult | null;
const hit = data?.movie_results?.[0] ?? data?.tv_results?.[0] ?? null;
if (hit?.poster_path) return `${TMDB_IMG_BASE}${hit.poster_path}`;
// No poster on the exact match — fall through to a typed/legacy search.
}
// 2/3/4. Type-aware movie/tv search with year preference.
if (kind !== null) {
const endpoint = kind === 'movie' ? 'movie' : 'tv';
const q = encodeURIComponent(searchName);
const url = `${TMDB_BASE}/search/${endpoint}?query=${q}&page=1`;
const data = (await this.fetchJson(url, headers)) as TmdbPosterSearchResult | null;
const poster = this.pickTmdbPoster(data?.results, year);
if (poster) return `${TMDB_IMG_BASE}${poster}`;
return null;
}
// Safety net: no type signal → legacy multi-search.
const q = encodeURIComponent(searchName);
const url = `${TMDB_BASE}/search/multi?query=${q}&page=1`;
const data = (await this.fetchJson(url, {
Authorization: `Bearer ${settings.tmdbApiKey}`,
})) as TmdbMultiResult | null;
const data = (await this.fetchJson(url, headers)) as TmdbMultiResult | null;
const poster = data?.results?.[0]?.poster_path;
if (poster) return `${TMDB_IMG_BASE}${poster}`;
return null;
}
if (settings.omdbApiKey) {
const key = encodeURIComponent(settings.omdbApiKey);
// 1. Most precise: resolve by IMDb id directly.
if (imdbId) {
const url = `${OMDB_BASE}/?i=${encodeURIComponent(imdbId)}&apikey=${key}`;
const data = (await this.fetchJson(url)) as OmdbResult | null;
const poster = data?.Poster;
if (poster && poster !== 'N/A') return poster;
// No poster on the exact match — fall through to a typed/legacy search.
}
const q = encodeURIComponent(searchName);
const url = `${OMDB_BASE}/?t=${q}&apikey=${encodeURIComponent(settings.omdbApiKey)}`;
// 2/3/4. Type-aware movie/series search, preferring the matching year.
if (kind !== null) {
const omdbType = kind === 'movie' ? 'movie' : 'series';
const base = `${OMDB_BASE}/?t=${q}&type=${omdbType}&apikey=${key}`;
let poster: string | undefined;
if (year !== null) {
const data = (await this.fetchJson(`${base}&y=${year}`)) as OmdbResult | null;
poster = data?.Poster;
}
if (!poster || poster === 'N/A') {
const data = (await this.fetchJson(base)) as OmdbResult | null;
poster = data?.Poster;
}
if (poster && poster !== 'N/A') return poster;
return null;
}
// Safety net: no type signal → legacy untyped lookup.
const url = `${OMDB_BASE}/?t=${q}&apikey=${key}`;
const data = (await this.fetchJson(url)) as OmdbResult | null;
const poster = data?.Poster;
if (poster && poster !== 'N/A') return poster;

View file

@ -732,6 +732,9 @@ export class ReadingDataManager {
lines.push(`dateFinished: ${item.dateFinished ?? 'null'}`);
lines.push(`releaseDate: ${item.releaseDate ?? 'null'}`);
lines.push(`dateAdded: ${yamlEscape(item.dateAdded)}`);
lines.push(`dateModified: ${yamlEscape(item.dateModified)}`);
if (item.coverUrl) lines.push(`coverUrl: ${yamlEscape(item.coverUrl)}`);
if (item.externalLink) lines.push(`externalLink: ${yamlEscape(item.externalLink)}`);
lines.push(`type: ${kind === 'book' ? 'book' : 'manga'}`);
lines.push('---');
return lines.join('\n');
@ -804,6 +807,25 @@ export class ReadingDataManager {
return true;
}
/**
* Rewrites ONLY the frontmatter block of an existing reading note, preserving
* the body verbatim (`## Notes` / `## Quotes`). Does NOT create the file when
* missing returns false. Mirrors writeReadingNote's update branch but never
* creates; used by the bulk "Rewrite all frontmatter" action.
*/
async rewriteFrontmatterIfExists(kind: ReadingKind, item: Book | Manga): Promise<boolean> {
const filePath = this.noteFilePath(kind, item.title);
const file = this.plugin.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return false;
const current = await this.plugin.app.vault.read(file);
const updated = this.rebuildWithFrontmatter(current, this.buildFrontmatter(kind, item));
if (updated !== current) {
await this.plugin.app.vault.modify(file, updated);
}
this.lastNotePathById.set(`${kind}:${item.id}`, filePath);
return true;
}
async readReadingNote(kind: ReadingKind, item: Book | Manga): Promise<string | null> {
const filePath = this.noteFilePath(kind, item.title);
const file = this.plugin.app.vault.getAbstractFileByPath(filePath);

View file

@ -3,14 +3,16 @@ import type WatchLogPlugin from './main';
import type { ReadingDataManager } from './ReadingDataManager';
import {
Book, Manga, ReadingCustomColumn, ReadingStatus, SELECTABLE_READING_STATUSES,
FIELD_COLOR_50, DEFAULT_FIELD_COLOR,
DEFAULT_FIELD_COLOR,
formatDateDisplay, parseDateInput,
} from './types';
import { deriveFillColor, deriveBorderColor } from './ColorPicker';
import { googleBooksErrorMessage } from './ApiService';
import { appendNoteLinkButton } from './NoteLinkButton';
import { ConfirmModal } from './ConfirmModal';
import { VaultFilePicker } from './AddReadingModal';
import { readingStatusColor, coverFallbackColor } from './ReadingTab';
import { openStatusDropdown } from './StatusDropdown';
import { ReadingManageColumnsModal } from './ReadingManageColumnsModal';
interface ParsedQuote {
@ -365,33 +367,14 @@ export class ReadingDetailModal extends Modal {
}
private openStatusDropdown(anchor: HTMLElement, wrap: HTMLElement): void {
this.contentEl.querySelectorAll('.wl-reading-status-dropdown').forEach((el) => el.remove());
const rect = anchor.getBoundingClientRect();
const dropdown = this.contentEl.createDiv({ cls: 'wl-reading-status-dropdown' });
dropdown.style.top = `${rect.bottom + 4}px`;
dropdown.style.left = `${rect.left}px`;
// Capture the owning document once so add/remove can't desync across popout windows.
const doc = this.contentEl.ownerDocument;
for (const status of SELECTABLE_READING_STATUSES) {
const opt = dropdown.createDiv({ cls: 'wl-reading-status-option' });
const dot = opt.createSpan({ cls: 'wl-reading-status-option-dot' });
dot.style.backgroundColor = readingStatusColor(status);
opt.createSpan({ text: status });
opt.addEventListener('click', () => {
dropdown.remove();
doc.removeEventListener('mousedown', closeListener, true);
void this.saveStatus(status).then(() => this.renderStatusBadge(wrap));
});
}
const closeListener = (e: MouseEvent): void => {
if (!dropdown.contains(e.target as Node)) {
dropdown.remove();
doc.removeEventListener('mousedown', closeListener, true);
}
};
window.setTimeout(() => doc.addEventListener('mousedown', closeListener, true), 0);
openStatusDropdown({
anchor,
container: this.contentEl,
options: [...SELECTABLE_READING_STATUSES],
getOptionColor: (status) => readingStatusColor(status as ReadingStatus),
onSelect: (status) =>
void this.saveStatus(status as ReadingStatus).then(() => this.renderStatusBadge(wrap)),
});
}
private async saveStatus(status: ReadingStatus): Promise<void> {
@ -1153,15 +1136,14 @@ export class ReadingDetailModal extends Modal {
const style = this.mode === 'book'
? (settings.bookCustomFieldStyle ?? 'fill')
: (settings.mangaCustomFieldStyle ?? 'fill');
const color600 = col.color ?? DEFAULT_FIELD_COLOR;
const color = col.color ?? DEFAULT_FIELD_COLOR;
// Fill/border looks live in CSS keyed off the table's is-border-mode class;
// only the per-column colour is dynamic.
// only the per-column colour is dynamic. Derivation handles custom hex too.
if (style === 'fill') {
const color50 = FIELD_COLOR_50[color600] ?? '#F1EFE8';
labelCell.setCssProps({ '--wl-field-bg': color50 });
labelCell.setCssProps({ '--wl-field-bg': deriveFillColor(color) });
} else {
labelCell.setCssProps({ '--wl-field-border': color600 });
labelCell.setCssProps({ '--wl-field-border': deriveBorderColor(color) });
}
const raw = item.customFields?.[col.id];

View file

@ -1,8 +1,9 @@
import { App, Modal, Notice } from 'obsidian';
import type WatchLogPlugin from './main';
import type { ReadingDataManager } from './ReadingDataManager';
import { ReadingCustomColumn, FIELD_COLORS, DEFAULT_FIELD_COLOR } from './types';
import { ReadingCustomColumn, DEFAULT_FIELD_COLOR } from './types';
import { ConfirmModal } from './ConfirmModal';
import { openColorPicker } from './ColorPicker';
type Kind = 'book' | 'manga';
@ -225,37 +226,13 @@ export class ReadingManageColumnsModal extends Modal {
}
private openColorPalette(anchor: HTMLElement, col: ReadingCustomColumn): void {
// Remove any existing palette
this.contentEl.querySelectorAll('.wl-reading-col-palette').forEach((el) => el.remove());
const rect = anchor.getBoundingClientRect();
const palette = this.contentEl.createDiv({ cls: 'wl-reading-col-palette' });
palette.style.top = `${rect.bottom + 4}px`;
palette.style.left = `${rect.left}px`;
// Capture the owning document once so add/remove can't desync across popout windows.
const doc = this.contentEl.ownerDocument;
const currentColor = col.color ?? DEFAULT_FIELD_COLOR;
for (const { color600, color50 } of FIELD_COLORS) {
const swatch = palette.createEl('button', { cls: 'wl-reading-col-palette-swatch' });
swatch.style.backgroundColor = color50;
swatch.style.borderColor = color600;
if (currentColor === color600) swatch.addClass('is-active');
swatch.title = color600;
swatch.addEventListener('click', (e) => {
e.stopPropagation();
palette.remove();
void this.updateColumn({ ...col, color: color600 });
});
}
const close = (e: MouseEvent): void => {
if (!palette.contains(e.target as Node) && e.target !== anchor) {
palette.remove();
doc.removeEventListener('mousedown', close, true);
}
};
window.setTimeout(() => doc.addEventListener('mousedown', close, true), 0);
openColorPicker({
anchor,
container: this.contentEl,
current: col.color ?? DEFAULT_FIELD_COLOR,
allowCustom: true,
onPick: (color) => { void this.updateColumn({ ...col, color: color ?? undefined }); },
});
}
private renderAddSection(parent: HTMLElement): void {

28
src/SearchThumbnail.ts Normal file
View file

@ -0,0 +1,28 @@
/**
* Renders a ~40×60 (2:3 poster) cover thumbnail for an add-flow search-result row.
*
* Mirrors the Cards-view fallback approach: a gray placeholder div carrying an icon
* sits underneath, and a lazily-loaded <img> is layered on top when a URL is present.
* If the image is missing or fails to load the <img> removes itself, revealing the
* placeholder so callers never need to special-case the no-image branch.
*
* Returns the thumbnail element (already appended to `parent`) so callers can place
* the title/meta column after it.
*/
export function renderSearchThumbnail(
parent: HTMLElement,
url: string | undefined,
alt: string,
fallbackIcon: string,
): HTMLElement {
const thumb = parent.createDiv({ cls: 'wl-result-thumb' });
thumb.createSpan({ cls: 'wl-result-thumb-icon', text: fallbackIcon });
if (url) {
const img = thumb.createEl('img', {
cls: 'wl-result-thumb-img',
attr: { src: url, alt, loading: 'lazy' },
});
img.onerror = () => { img.remove(); };
}
return thumb;
}

View file

@ -1,7 +1,7 @@
import { App, Notice, Platform, PluginSettingTab, Setting, normalizePath } from 'obsidian';
import type WatchLogPlugin from './main';
import type { TagDefinition, ReadingStatus, ReadingData } from './types';
import { SELECTABLE_READING_STATUSES } from './types';
import { SELECTABLE_READING_STATUSES, LAST_USED_TYPE_SENTINEL } from './types';
import { CsvModal } from './CsvModal';
import { ReadingCsvChoiceModal } from './ReadingCsvChoiceModal';
import { ReadingCsvModal } from './ReadingCsvModal';
@ -130,6 +130,21 @@ export class WatchLogSettingsTab extends PluginSettingTab {
}),
);
new Setting(el)
.setName('Rewrite all note frontmatter')
.setDesc('Rewrites the YAML frontmatter on every existing Watchlist and Reading note, applying newer frontmatter fields to older notes. Body content (## Notes / ## Quotes) is preserved. Notes without a file on disk are skipped — use Regenerate to create them.')
.addButton((b) =>
b.setButtonText('Rewrite frontmatter').onClick(() => {
const titles = this.plugin.dataManager.getTitles();
const readingCount = this.plugin.readingDataManager.getBooks().length + this.plugin.readingDataManager.getMangaList().length;
new ConfirmModal(
this.app,
`This will rewrite the YAML frontmatter on all existing notes for ${titles.length} watch titles and ${readingCount} reading entries. Body content is preserved; notes without a file on disk are skipped. Continue?`,
() => { void this.rewriteAllFrontmatter(); },
).open();
}),
);
new Setting(el)
.setName('Show hint banners')
.setDesc('Show informational hint banners in Upcoming, Custom Lists, and Drafts tabs.')
@ -404,6 +419,76 @@ export class WatchLogSettingsTab extends PluginSettingTab {
new Notice('Cached posters and covers cleared. Watch posters reload as you browse; refresh reading covers from each card\'s ⋮ menu.');
}
/**
* Retroactively rewrites the frontmatter block on every existing note
* (Watchlist titles + Reading books/manga), preserving each body. Reuses the
* body-preserving frontmatter-only update path; missing files are skipped (no
* creation). Processes in chunks with a live, cancelable progress notice and
* exposes cancellation via the shared importProgress infrastructure.
*/
private async rewriteAllFrontmatter(): Promise<void> {
const dm = this.plugin.dataManager;
const reading = this.plugin.readingDataManager;
const titles = dm.getTitles();
const books = reading.getBooks();
const manga = reading.getMangaList();
const total = titles.length + books.length + manga.length;
if (total === 0) {
new Notice('No notes to rewrite.');
return;
}
// One flat list of work units; each returns true when a file was rewritten,
// false when it was skipped (no file on disk).
const jobs: Array<() => Promise<boolean>> = [
...titles.map((t) => () => dm.rewriteFrontmatterIfExists(t)),
...books.map((b) => () => reading.rewriteFrontmatterIfExists('book', b)),
...manga.map((m) => () => reading.rewriteFrontmatterIfExists('manga', m)),
];
let cancelled = false;
const cancelFn = (): void => { cancelled = true; };
// Expose to the shared progress infra (Watchlist header renders its cancel
// button) and re-render so it appears.
this.plugin.importProgress = { current: 0, total, cancel: cancelFn };
dm.notifyChange();
// Live, in-place progress notice with its own cancel button (the user is in
// Settings, where the header bar isn't visible). Persistent (timeout 0).
const notice = new Notice('', 0);
const label = notice.messageEl.createSpan({ text: `Rewriting frontmatter… 0 / ${total}` });
const cancelBtn = notice.messageEl.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Cancel' });
cancelBtn.setCssProps({ 'margin-left': '8px' });
cancelBtn.addEventListener('click', cancelFn);
const CHUNK_SIZE = 10;
let rewritten = 0;
let skipped = 0;
let done = 0;
try {
for (let i = 0; i < jobs.length; i++) {
if (cancelled) break;
if (await jobs[i]!()) rewritten++; else skipped++;
done++;
// Update progress and yield to the event loop every chunk so the UI
// can repaint and stays responsive across 700+ notes.
if (done % CHUNK_SIZE === 0 || done === total) {
this.plugin.importProgress = { current: done, total, cancel: cancelFn };
label.textContent = `Rewriting frontmatter… ${done} / ${total}`;
await new Promise((r) => window.setTimeout(r, 0));
}
}
} finally {
this.plugin.importProgress = null;
dm.notifyChange();
notice.hide();
}
const skippedMsg = skipped > 0 ? `, ${skipped} skipped (no file)` : '';
const prefix = cancelled ? 'Cancelled — ' : 'Done — ';
new Notice(`${prefix}${rewritten} note${rewritten !== 1 ? 's' : ''} rewritten${skippedMsg}.`);
}
private async runRegenerate(
progressWrap: HTMLElement,
progressBarFill: HTMLElement,
@ -976,6 +1061,22 @@ export class WatchLogSettingsTab extends PluginSettingTab {
}),
);
new Setting(el)
.setName('Default type for new titles')
.setDesc('Type preselected when adding a title. "Last type used" follows whatever you added last.')
.addDropdown((d) => {
d.addOption(LAST_USED_TYPE_SENTINEL, '* Last type used');
for (const t of this.plugin.settings.types) {
d.addOption(t.name, t.name);
}
d.setValue(this.plugin.settings.defaultAddType ?? LAST_USED_TYPE_SENTINEL).onChange(
async (v) => {
this.plugin.settings.defaultAddType = v;
await this.plugin.saveSettings();
},
);
});
new Setting(el)
.setName('Auto-complete on last episode')
.setDesc('Mark status as completed when all episodes are watched.')

82
src/StatusDropdown.ts Normal file
View file

@ -0,0 +1,82 @@
// ─────────────────────────────────────────────────────────────────────────────
// StatusDropdown — shared "colored dot + label" selection popover.
//
// Extracted from the duplicated status dropdowns in TitleDetailModal /
// ReadingDetailModal so the same component serves user-configurable status
// colours (Watchlist / Reading) and Custom Lists per-option colours. The
// component makes NO assumption about where colours come from: the caller
// supplies them via `getOptionColor(value)`. Options whose colour resolves to
// null/undefined fall back to the dropdown's default (neutral) dot appearance.
//
// Open/position/dismiss behaviour and the dot + label rendering match the
// original Reading status dropdown exactly (it reuses the same CSS classes).
// ─────────────────────────────────────────────────────────────────────────────
export interface StatusDropdownOption {
value: string;
/** Display label; defaults to `value`. */
label?: string;
}
export interface StatusDropdownOptions {
/** Element the dropdown is positioned beneath (the badge / cell). */
anchor: HTMLElement;
/** Element the dropdown is appended to. Positioned `fixed`, so any container
* works; also scopes the "remove existing dropdown" cleanup and owns the
* document used for the dismiss listener. */
container: HTMLElement;
/** Options to render, as plain strings or `{ value, label }`. */
options: (string | StatusDropdownOption)[];
/** Resolves the dot colour for an option value. Return null/undefined to use
* the default neutral dot. */
getOptionColor?: (value: string) => string | null | undefined;
/** Called with the chosen value. The dropdown closes itself first. */
onSelect: (value: string) => void;
/** Called when the dropdown is dismissed without a selection (outside click). */
onClose?: () => void;
}
/**
* Opens the shared status-selection dropdown beneath `anchor`. Any dropdown
* already open in `container` is removed first.
*/
export function openStatusDropdown(opts: StatusDropdownOptions): void {
const { anchor, container, options, getOptionColor, onSelect, onClose } = opts;
container.querySelectorAll('.wl-reading-status-dropdown').forEach((el) => el.remove());
const rect = anchor.getBoundingClientRect();
const dropdown = container.createDiv({ cls: 'wl-reading-status-dropdown' });
dropdown.style.top = `${rect.bottom + 4}px`;
dropdown.style.left = `${rect.left}px`;
// Capture the owning document once so add/remove can't desync across popout windows.
const doc = container.ownerDocument;
let picked = false;
for (const entry of options) {
const value = typeof entry === 'string' ? entry : entry.value;
const label = typeof entry === 'string' ? entry : (entry.label ?? entry.value);
const opt = dropdown.createDiv({ cls: 'wl-reading-status-option' });
const dot = opt.createSpan({ cls: 'wl-reading-status-option-dot' });
const color = getOptionColor?.(value);
// No colour → leave the dot to its default CSS appearance.
if (color) dot.style.backgroundColor = color;
opt.createSpan({ text: label });
opt.addEventListener('click', () => {
picked = true;
dropdown.remove();
doc.removeEventListener('mousedown', closeListener, true);
onSelect(value);
});
}
const closeListener = (e: MouseEvent): void => {
if (!dropdown.contains(e.target as Node)) {
dropdown.remove();
doc.removeEventListener('mousedown', closeListener, true);
if (!picked) onClose?.();
}
};
window.setTimeout(() => doc.addEventListener('mousedown', closeListener, true), 0);
}

View file

@ -1,23 +1,31 @@
import { App, Modal } from 'obsidian';
import { App, Modal, Notice, setIcon } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { WatchLogTitle, WatchLogGroup, Season, TagDefinition } from './types';
import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDisplayPoster } from './types';
import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDisplayPoster, getDisplayTrailer, getApiGroupForType, getDisplayCredits, isCreditsUnfetched, needsCreditsFetch, CREDITS_NONE } from './types';
import { appendNoteLinkButton } from './NoteLinkButton';
import { EditTitleModal } from './EditTitleModal';
import { ConfirmModal } from './ConfirmModal';
import { renderCommunityRating, maybeAutoRefreshCommunityRating } from './CommunityRating';
import { openStatusDropdown } from './StatusDropdown';
// Titles whose director/cast backfill is currently in flight — de-dupes rapid
// modal re-opens (same pattern as CommunityRating's in-flight set).
const creditsBackfillInFlight = new Set<string>();
export class TitleDetailModal extends Modal {
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private title: WatchLogTitle;
private onChanged?: () => void;
private onPersonClick?: (name: string) => void;
private changed = false;
private notesEditedByUser = false;
private collapsedSeasons: Set<number> = new Set();
// Section anchors so we can re-render incrementally
private statsBoxEl: HTMLElement | null = null;
private creditsEl: HTMLElement | null = null;
private episodesSectionEl: HTMLElement | null = null;
private starsWrapEl: HTMLElement | null = null;
@ -26,12 +34,14 @@ export class TitleDetailModal extends Modal {
plugin: WatchLogPlugin,
title: WatchLogTitle,
onChanged?: () => void,
onPersonClick?: (name: string) => void,
) {
super(app);
this.plugin = plugin;
this.dataManager = plugin.dataManager;
this.title = title;
this.onChanged = onChanged;
this.onPersonClick = onPersonClick;
this.collapsedSeasons = this.dataManager.getCollapsedSeasonsForTitle(title.id);
}
@ -81,6 +91,10 @@ export class TitleDetailModal extends Modal {
// ── Header ────────────────────────────────────────────────────────────────
private renderHeader(parent: HTMLElement): void {
const header = parent.createDiv({ cls: 'wl-detail-header' });
// Cover on the left, mirroring ReadingTab's in-flow aspect-ratio cover box.
this.renderCover(header);
const left = header.createDiv({ cls: 'wl-detail-header-left' });
left.createEl('h2', { cls: 'wl-detail-title', text: this.title.title });
@ -119,8 +133,105 @@ export class TitleDetailModal extends Modal {
const statusWrap = badgeRow.createSpan({ cls: 'wl-reading-detail-status-wrap' });
this.renderStatusBadge(statusWrap);
this.statsBoxEl = header.createDiv({ cls: 'wl-detail-stats-box' });
this.statsBoxEl = left.createDiv({ cls: 'wl-detail-stats-box' });
this.renderStatsBox();
// Director / Cast rows directly below the stats row. Missing fields kick
// off the one-time lazy backfill; the section re-renders when it lands.
this.creditsEl = left.createDiv({ cls: 'wl-detail-credits' });
this.renderCreditsSection();
this.maybeBackfillCredits();
}
// ── Credits (director / cast) ─────────────────────────────────────────────
private renderCreditsSection(): void {
if (!this.creditsEl) return;
this.creditsEl.empty();
renderCreditsRows(
this.creditsEl,
this.title,
this.onPersonClick
? (name) => {
this.close();
this.onPersonClick!(name);
}
: undefined,
);
}
/**
* One-time director/cast backfill for titles that predate the fields. Fetches
* from the title's stored IMDb link, then persists through the normal update
* path (data.json + markdown frontmatter in the same operation). A failed or
* empty fetch marks the fields 'none' so no later open re-attempts it.
*/
private maybeBackfillCredits(): void {
const t = this.dataManager.getTitle(this.title.id);
if (!t || !needsCreditsFetch(t) || creditsBackfillInFlight.has(t.id)) return;
creditsBackfillInFlight.add(t.id);
void (async () => {
try {
const fetched = await this.plugin.apiService.fetchCredits(
t,
this.plugin.settings.activeApi ?? 'OMDb',
);
// Only absent fields are filled; fields that already hold data (or a
// 'none' marker) are kept as-is.
const director = isCreditsUnfetched(t.director) ? (fetched?.director ?? [CREDITS_NONE]) : t.director!;
const cast = isCreditsUnfetched(t.cast) ? (fetched?.cast ?? [CREDITS_NONE]) : t.cast!;
await this.dataManager.updateCredits(t.id, director, cast);
} catch {
// Persistence failed — fields stay unfetched, so the next open retries.
} finally {
creditsBackfillInFlight.delete(t.id);
}
this.refreshTitle();
this.renderCreditsSection();
})();
}
// Cover box mirroring the Cards view: shows the title's poster when one is
// available (manual or auto-fetched), otherwise the type-colored letter
// placeholder — same resolution the card uses, so the modal reflects the card
// cover. The box reserves its aspect-ratio space from first paint (no shift).
private renderCover(parent: HTMLElement): void {
const cover = parent.createDiv({ cls: 'wl-detail-cover' });
const typeDef = this.getTagDef(this.title.type, this.plugin.settings.types);
const typeColor = typeDef
? getThemedColor(this.title.type, typeDef.color, this.plugin.settings.colorTheme)
: '#888780';
const placeholder = cover.createDiv({ cls: 'wl-detail-cover-placeholder' });
placeholder.style.backgroundColor = typeColor;
const letter = (this.title.title.trim().charAt(0) || '?').toUpperCase();
placeholder.createSpan({ text: letter });
const img = cover.createEl('img', { cls: 'wl-detail-cover-img' });
img.alt = this.title.title;
const showImg = (url: string): void => {
img.src = url;
cover.addClass('has-poster');
};
const showPlaceholder = (): void => {
cover.removeClass('has-poster');
};
const display = getDisplayPoster(this.title);
const isManual = !!(this.title.manualPosterUrl && this.title.manualPosterUrl.trim() !== '');
if (display && display.startsWith('http')) {
showImg(display);
img.onerror = () => {
showPlaceholder();
if (!isManual) void this.dataManager.updatePosterUrl(this.title.id, 'none');
};
} else if (!isManual && this.title.posterUrl === '') {
placeholder.addClass('is-loading');
void this.plugin.posterService?.enqueue(this.title).then((url) => {
placeholder.removeClass('is-loading');
if (url) showImg(url);
});
}
}
private renderStatsBox(): void {
@ -297,12 +408,81 @@ export class TitleDetailModal extends Modal {
toRemove.parentNode?.removeChild(toRemove);
}
renderCommunityRating(wrap, this.plugin, this.title.id, rerenderCommunity);
this.renderTrailerControl(wrap);
this.refreshTitle();
};
renderCommunityRating(wrap, this.plugin, this.title.id, rerenderCommunity);
// Trailer sits at the tail of the row: rating badge → refresh → YouTube.
this.renderTrailerControl(wrap);
maybeAutoRefreshCommunityRating(this.plugin, this.title.id, rerenderCommunity);
}
/** Appends the trailer control (its own container) to the rating row. */
private renderTrailerControl(parent: HTMLElement): void {
const container = parent.createSpan({ cls: 'wl-trailer-control' });
this.paintTrailerControl(container);
}
/**
* Paints the trailer control into its container:
* - has a trailer clickable YouTube icon (opens externally), no refresh.
* - no trailer ('' or 'none') a refresh button that re-fetches from source.
* Re-read from the store so an override saved via the edit modal is reflected.
*/
private paintTrailerControl(container: HTMLElement): void {
container.empty();
const title = this.dataManager.getTitle(this.title.id) ?? this.title;
const url = getDisplayTrailer(title);
if (url) {
const btn = container.createEl('button', {
cls: 'wl-trailer-btn',
attr: { type: 'button', title: 'Watch trailer on YouTube', 'aria-label': 'Watch trailer on YouTube' },
});
setIcon(btn, 'youtube');
btn.addEventListener('click', (e) => {
e.stopPropagation();
window.open(url, '_blank');
});
return;
}
const refreshBtn = container.createEl('button', {
cls: 'wl-community-refresh wl-trailer-refresh',
attr: { type: 'button', title: 'Fetch trailer', 'aria-label': 'Fetch trailer' },
});
setIcon(refreshBtn, 'refresh-cw');
refreshBtn.addEventListener('click', (e) => {
e.stopPropagation();
void this.refreshTrailer(refreshBtn, container);
});
}
/**
* Re-fetches the trailer from the title's source (same path as add-time). On a
* hit it stores the URL and repaints the control (now the YouTube icon); on a
* miss it marks the trailer 'none', notifies, and keeps the refresh button.
*/
private async refreshTrailer(btn: HTMLElement, container: HTMLElement): Promise<void> {
const title = this.dataManager.getTitle(this.title.id);
if (!title) return;
btn.addClass('is-loading');
try {
const animeSource = this.plugin.settings.animeApiSource ?? 'jikan';
const group = getApiGroupForType(title.type, this.plugin.settings.typeApiMapping);
const url = await this.plugin.apiService.fetchTrailer(title, animeSource, group);
const hit = !!url && url !== 'none';
this.dataManager.updateTrailerUrl(title.id, hit ? url : 'none');
if (hit) {
this.paintTrailerControl(container);
} else {
new Notice('No trailer found');
btn.removeClass('is-loading');
}
} catch {
new Notice('No trailer found');
btn.removeClass('is-loading');
}
}
private renderStars(): void {
if (!this.starsWrapEl) return;
this.starsWrapEl.empty();
@ -338,8 +518,25 @@ export class TitleDetailModal extends Modal {
textarea.setCssProps({ height: 'auto' });
textarea.setCssProps({ height: `${textarea.scrollHeight}px` });
};
textarea.addEventListener('input', autoResize);
textarea.addEventListener('input', () => {
this.notesEditedByUser = true;
autoResize();
});
window.setTimeout(autoResize, 0);
// Pull the on-disk `## Notes` body into the field so edits made directly in
// the .md file show up here (mirrors EditTitleModal / Reading's read-back).
// Skipped once the user starts typing, to avoid clobbering their input.
void this.dataManager.readNotesFromFile(this.title).then((fileNotes) => {
if (fileNotes === null || this.notesEditedByUser) return;
if (fileNotes !== textarea.value) {
textarea.value = fileNotes;
autoResize();
}
// Align the in-memory model so an unchanged field isn't re-saved on blur.
this.title.notes = fileNotes;
});
textarea.addEventListener('blur', () => {
if (textarea.value === this.title.notes) return;
void (async () => {
@ -420,34 +617,15 @@ export class TitleDetailModal extends Modal {
}
private openStatusDropdown(anchor: HTMLElement, wrap: HTMLElement): void {
this.contentEl.querySelectorAll('.wl-reading-status-dropdown').forEach((el) => el.remove());
const rect = anchor.getBoundingClientRect();
const dropdown = this.contentEl.createDiv({ cls: 'wl-reading-status-dropdown' });
dropdown.style.top = `${rect.bottom + 4}px`;
dropdown.style.left = `${rect.left}px`;
// Capture the owning document once so add/remove can't desync across popout windows.
const doc = this.contentEl.ownerDocument;
for (const s of this.plugin.settings.statuses) {
if (s.name === 'To be released') continue;
const opt = dropdown.createDiv({ cls: 'wl-reading-status-option' });
const dot = opt.createSpan({ cls: 'wl-reading-status-option-dot' });
dot.style.backgroundColor = this.statusColor(s.name);
opt.createSpan({ text: s.name });
opt.addEventListener('click', () => {
dropdown.remove();
doc.removeEventListener('mousedown', closeListener, true);
void this.saveStatus(s.name).then(() => this.renderStatusBadge(wrap));
});
}
const closeListener = (e: MouseEvent): void => {
if (!dropdown.contains(e.target as Node)) {
dropdown.remove();
doc.removeEventListener('mousedown', closeListener, true);
}
};
window.setTimeout(() => doc.addEventListener('mousedown', closeListener, true), 0);
openStatusDropdown({
anchor,
container: this.contentEl,
options: this.plugin.settings.statuses
.filter((s) => s.name !== 'To be released')
.map((s) => s.name),
getOptionColor: (name) => this.statusColor(name),
onSelect: (name) => void this.saveStatus(name).then(() => this.renderStatusBadge(wrap)),
});
}
private async saveStatus(status: string): Promise<void> {
@ -494,6 +672,42 @@ export class TitleDetailModal extends Modal {
}
}
// ── Credits rows (shared) ───────────────────────────────────────────────────
// Renders the Director / Cast rows used by both the detail modal and the List
// view's expanded row. Unfetched fields render nothing; the 'none' sentinel
// renders the row with an em-dash fallback; real names render as clickable
// person-filter chips when a handler is provided.
export function renderCreditsRows(
parent: HTMLElement,
title: WatchLogTitle,
onPersonClick?: (name: string) => void,
): void {
const renderRow = (label: string, names: string[] | undefined): void => {
if (isCreditsUnfetched(names)) return;
const row = parent.createDiv({ cls: 'wl-credits-row' });
row.createSpan({ cls: 'wl-credits-label', text: label });
const values = getDisplayCredits(names);
if (values.length === 0) {
row.createSpan({ cls: 'wl-credits-empty', text: '—' });
return;
}
values.forEach((name, i) => {
if (i > 0) row.createSpan({ cls: 'wl-credits-sep', text: ', ' });
const el = row.createSpan({ cls: 'wl-credits-name', text: name });
if (onPersonClick) {
el.addClass('is-clickable');
el.title = `Show titles with ${name}`;
el.addEventListener('click', (e) => {
e.stopPropagation();
onPersonClick(name);
});
}
});
};
renderRow('Director', title.director);
renderRow('Cast', title.cast);
}
// ── Group detail modal ──────────────────────────────────────────────────────
export class GroupDetailModal extends Modal {
private plugin: WatchLogPlugin;
@ -501,6 +715,7 @@ export class GroupDetailModal extends Modal {
private group: WatchLogGroup;
private members: WatchLogTitle[];
private onChanged?: () => void;
private onPersonClick?: (name: string) => void;
private changed = false;
constructor(
@ -509,6 +724,7 @@ export class GroupDetailModal extends Modal {
group: WatchLogGroup,
members: WatchLogTitle[],
onChanged?: () => void,
onPersonClick?: (name: string) => void,
) {
super(app);
this.plugin = plugin;
@ -516,6 +732,7 @@ export class GroupDetailModal extends Modal {
this.group = group;
this.members = members;
this.onChanged = onChanged;
this.onPersonClick = onPersonClick;
}
onOpen(): void {
@ -563,7 +780,7 @@ export class GroupDetailModal extends Modal {
new TitleDetailModal(this.plugin.app, this.plugin, m, () => {
this.changed = true;
if (this.onChanged) this.onChanged();
}).open();
}, this.onPersonClick).open();
},
onEdit: () => {
this.close();

View file

@ -1,4 +1,4 @@
import { ItemView, Platform, WorkspaceLeaf } from 'obsidian';
import { ItemView, Platform, setIcon, WorkspaceLeaf } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import { DashboardTab } from './DashboardTab';
@ -286,38 +286,22 @@ export class WatchLogView extends ItemView {
const root = this.contentEl;
root.empty();
root.addClass('wl-view');
// Mobile gets icon-only tabs and the container-responsive dashboard grid;
// desktop keeps text labels and the fixed 3-column layout. Toggled via this
// class so styling keys off the established Platform.isMobile check, not a
// viewport media query.
root.toggleClass('wl-mobile', Platform.isMobile);
this.applyColorTheme(root);
// Tab bar
const tabBar = root.createDiv({ cls: 'wl-tab-bar' });
const dashBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'dashboard' ? ' is-active' : ''}`,
text: 'Dashboard',
});
const listBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'watchlist' ? ' is-active' : ''}`,
text: 'Watchlist',
});
const readingBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'reading' ? ' is-active' : ''}`,
text: 'Reading',
});
this.airtimeBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'upcoming' ? ' is-active' : ''}`,
text: 'Upcoming',
});
const customListsBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'custom-lists' ? ' is-active' : ''}`,
text: 'Custom lists',
});
this.draftsBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'drafts' ? ' is-active' : ''}`,
text: 'Drafts',
});
const logBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'log' ? ' is-active' : ''}`,
text: 'Log',
});
const dashBtn = this.makeTabBtn(tabBar, 'dashboard', 'Dashboard', 'layout-dashboard');
const listBtn = this.makeTabBtn(tabBar, 'watchlist', 'Watchlist', 'tv');
const readingBtn = this.makeTabBtn(tabBar, 'reading', 'Reading', 'book-open');
this.airtimeBtn = this.makeTabBtn(tabBar, 'upcoming', 'Upcoming', 'calendar');
const customListsBtn = this.makeTabBtn(tabBar, 'custom-lists', 'Custom lists', 'list');
this.draftsBtn = this.makeTabBtn(tabBar, 'drafts', 'Drafts', 'square-pen');
const logBtn = this.makeTabBtn(tabBar, 'log', 'Log', 'history');
const allBtns = [dashBtn, listBtn, this.airtimeBtn, readingBtn, customListsBtn, this.draftsBtn, logBtn];
this.tabButtons = {
@ -409,6 +393,37 @@ export class WatchLogView extends ItemView {
}));
}
/**
* Creates a tab-bar button. On mobile it renders icon-only (Lucide via setIcon)
* with the label as the accessible name; on desktop it shows the text label.
*/
private makeTabBtn(tabBar: HTMLElement, tab: TabName, label: string, icon: string): HTMLButtonElement {
const btn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === tab ? ' is-active' : ''}`,
});
if (Platform.isMobile) {
btn.addClass('wl-tab-btn-icon');
setIcon(btn, icon);
btn.setAttribute('aria-label', label);
} else {
btn.setText(label);
}
return btn;
}
/**
* Applies a count badge to a tab button. Desktop shows "Label (N)" text; mobile
* (icon mode) can't overwrite the icon, so it toggles a small purple dot.
*/
private setTabBadge(btn: HTMLButtonElement | null, label: string, count: number): void {
if (!btn) return;
if (Platform.isMobile) {
btn.toggleClass('wl-tab-has-badge', count > 0);
} else {
btn.textContent = count > 0 ? `${label} (${count})` : label;
}
}
/**
* Programmatically switch tabs (e.g. from the status-bar Upcoming counter).
* Mirrors a tab-button click: updates active state and re-renders the content.
@ -451,9 +466,7 @@ export class WatchLogView extends ItemView {
this.listTab.render();
} else if (this.activeTab === 'upcoming') {
this.airtimeTab = new AirtimeTab(this.tabContentEl, this.plugin, this.dataManager, (count) => {
if (this.airtimeBtn) {
this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming';
}
this.setTabBadge(this.airtimeBtn, 'Upcoming', count);
});
this.airtimeTab.render();
} else if (this.activeTab === 'reading') {
@ -537,13 +550,11 @@ export class WatchLogView extends ItemView {
private updateUpcomingBadge(): void {
if (!this.airtimeBtn) return;
const count = AirtimeTab.getAiredDueCount(this.dataManager, this.plugin.readingDataManager) + AirtimeTab.getMaybeDueCount(this.dataManager);
this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming';
this.setTabBadge(this.airtimeBtn, 'Upcoming', count);
}
private setDraftsBadge(count: number): void {
if (this.draftsBtn) {
this.draftsBtn.textContent = count > 0 ? `Drafts (${count})` : 'Drafts';
}
this.setTabBadge(this.draftsBtn, 'Drafts', count);
}
/** Count-only drafts scan to keep the tab badge correct even before the Drafts tab is opened. */

View file

@ -1,5 +1,5 @@
import { App, Editor, FuzzySuggestModal, MarkdownView, Notice, Platform, Plugin, normalizePath, setIcon } from 'obsidian';
import { DEFAULT_SETTINGS, WatchLogPluginSettings, AirtimeSchedule } from './types';
import { DEFAULT_SETTINGS, LAST_USED_TYPE_SENTINEL, WatchLogPluginSettings, AirtimeSchedule } from './types';
import type { ReadingData } from './types';
import type { HistoryEntry } from './HistoryManager';
import { DataManager } from './DataManager';
@ -298,6 +298,13 @@ export default class WatchLogPlugin extends Plugin {
if (this.settings.showUpcomingStatusBar === undefined) {
this.settings.showUpcomingStatusBar = true;
}
// Backfill the default Add type setting for existing installs.
if (this.settings.defaultAddType === undefined) {
this.settings.defaultAddType = LAST_USED_TYPE_SENTINEL;
}
if (this.settings.lastAddedType === undefined) {
this.settings.lastAddedType = '';
}
// Ensure array fields are never undefined after a partial merge
if (!this.settings.types?.length) this.settings.types = DEFAULT_SETTINGS.types;
if (!this.settings.statuses?.length) this.settings.statuses = DEFAULT_SETTINGS.statuses;
@ -320,6 +327,25 @@ export default class WatchLogPlugin extends Plugin {
await this.dataManager.saveSettings(this.settings);
}
/**
* Resolves the type slug the Add modal should preselect. Precedence:
* 1. If `defaultAddType` is the sentinel the last added type if it still
* exists, otherwise the first available type.
* 2. If `defaultAddType` is a real, still-existing type that type.
* 3. Otherwise (stored type was deleted/renamed) the first available type.
* Never returns a non-existent type as long as at least one type exists.
*/
resolveDefaultAddType(): string {
const types = this.settings.types ?? [];
const firstAvailable = types[0]?.name ?? '';
const exists = (name: string): boolean => !!name && types.some((t) => t.name === name);
const choice = this.settings.defaultAddType ?? LAST_USED_TYPE_SENTINEL;
if (choice === LAST_USED_TYPE_SENTINEL) {
return exists(this.settings.lastAddedType) ? this.settings.lastAddedType : firstAvailable;
}
return exists(choice) ? choice : firstAvailable;
}
/**
* One-time migration of legacy `reading.json` / `history.json` into `data.json`.
*

View file

@ -38,6 +38,52 @@ export interface WatchLogTitle {
posterUrl?: string;
/** User-supplied override — takes priority over posterUrl when non-empty. */
manualPosterUrl?: string;
/** '' = unfetched, 'none' = source without a trailer (e.g. OMDb), or a YouTube URL. */
trailerUrl?: string;
/** User-supplied override — takes priority over trailerUrl when non-empty. */
manualTrailerUrl?: string;
/** Director name(s). Absent/empty = unfetched, ['none'] = fetch failed or unavailable. */
director?: string[];
/** Cast names, capped at MAX_CAST. Absent/empty = unfetched, ['none'] = fetch failed or unavailable. */
cast?: string[];
}
// ── Credits (director / cast) ────────────────────────────────────────────────
/**
* Hardcoded cast cap applied to every source, so a title looks the same
* whether it came from OMDb (4 actors natively) or TMDB (full cast, sliced).
*/
export const MAX_CAST = 4;
/** Sentinel marking a credits field whose fetch failed or returned nothing (posterUrl's 'none' pattern). */
export const CREDITS_NONE = 'none';
/** True when the field was fetched but yielded nothing — never re-fetch. */
export function isCreditsNone(names: string[] | undefined): boolean {
return names?.length === 1 && names[0] === CREDITS_NONE;
}
/** True when the field has never been fetched (absent or empty). */
export function isCreditsUnfetched(names: string[] | undefined): boolean {
return !names || names.length === 0;
}
/** True when either credits field still needs its one-time fetch. */
export function needsCreditsFetch(title: WatchLogTitle): boolean {
return isCreditsUnfetched(title.director) || isCreditsUnfetched(title.cast);
}
/** Names to render/search: real values only — unfetched and 'none' both yield []. */
export function getDisplayCredits(names: string[] | undefined): string[] {
if (!names || isCreditsNone(names)) return [];
return names;
}
/** Normalizes a fetched name list for storage: caps cast, maps empty to the 'none' sentinel. */
export function normalizeCreditsForStore(names: string[]): string[] {
const cleaned = names.map((n) => n.trim()).filter((n) => n !== '' && n !== 'N/A');
return cleaned.length > 0 ? cleaned.slice(0, MAX_CAST) : [CREDITS_NONE];
}
export interface WatchLogGroup {
@ -60,9 +106,12 @@ export interface CustomListColumn {
type: 'text' | 'number' | 'select';
locked?: boolean;
options?: string[]; // only for type: 'select'
optionColors?: Record<string, string>; // option value → hex; only for type: 'select'
bold?: boolean; // only for type: 'text' | 'number'
italic?: boolean; // only for type: 'text' | 'number'
autoTime?: boolean; // only for type: 'number' — auto-populate with remaining watch time
/** Explicit rendered width in px. Absent = use the default flex width. Dropped with the column. */
width?: number;
}
export interface CustomListRow {
@ -89,6 +138,9 @@ export interface CustomList {
columns: CustomListColumn[];
rows: CustomListRow[];
notes: string;
tabColor?: string; // hex; colours this list's tab name. Unset = default.
/** Explicit width in px for the built-in Name column. Absent = default flex width. */
nameWidth?: number;
}
// ── Drafts types ─────────────────────────────────────────────────────────────
@ -121,6 +173,13 @@ export interface WatchLogPluginSettings {
seasonPalette: string[];
dashboardCardStyle: 'circles' | 'rectangles';
episodeNumbering: 'absolute' | 'per-season';
/**
* Default type preselected in the Add modal. Either a real type slug (a
* `types[].name`) or `LAST_USED_TYPE_SENTINEL` to follow the last added type.
*/
defaultAddType: string;
/** Type slug of the most recently added title. Updated silently on every add. */
lastAddedType: string;
/** User-configurable colors for the Reading type badges (Manga / Book). */
readingTypeColors: { manga: string; book: string };
customListsFolder: string;
@ -371,6 +430,12 @@ export function getAirtimeScheduleString(schedule: AirtimeSchedule): string {
return '—';
}
/**
* Sentinel value for `defaultAddType` meaning "use the last type the user added".
* A real type slug (a `types[].name`) can never collide with this.
*/
export const LAST_USED_TYPE_SENTINEL = '__wl_last_used__';
export const DEFAULT_SETTINGS: WatchLogPluginSettings = {
colorTheme: 'default',
defaultWatchlistView: 'cards',
@ -409,8 +474,10 @@ export const DEFAULT_SETTINGS: WatchLogPluginSettings = {
rootFolder: 'WatchLog',
autoCreateFolders: true,
coloredTypeBadges: true,
dashboardCardStyle: 'circles',
dashboardCardStyle: 'rectangles',
episodeNumbering: 'absolute',
defaultAddType: LAST_USED_TYPE_SENTINEL,
lastAddedType: '',
readingTypeColors: { manga: '#D4537E', book: '#D85A30' },
customListsFolder: 'WatchLog/CustomLists',
defaultCustomColumns: [],
@ -547,6 +614,18 @@ export function getDisplayPoster(title: WatchLogTitle): string {
return title.posterUrl ?? '';
}
/**
* Returns the trailer URL to render for a title. A non-empty `manualTrailerUrl`
* takes priority over the auto-fetched `trailerUrl`; `'none'` (source without a
* trailer) and `''` (unfetched) both resolve to an empty string.
*/
export function getDisplayTrailer(title: WatchLogTitle): string {
if (title.manualTrailerUrl && title.manualTrailerUrl.trim() !== '') {
return title.manualTrailerUrl;
}
return title.trailerUrl && title.trailerUrl !== 'none' ? title.trailerUrl : '';
}
export function formatVoteCount(votes: number): string {
if (!votes || votes < 0) return '0';
if (votes >= 1_000_000) return (votes / 1_000_000).toFixed(1) + 'M';
@ -579,6 +658,8 @@ export interface AnimeSearchResult {
averageScore?: number;
genres?: string[];
posterUrl?: string;
/** '' = unresolved, 'none' = no trailer, or a YouTube watch URL. */
trailerUrl?: string;
}
// ── AniList API shapes ───────────────────────────────────────────────────────
@ -614,6 +695,7 @@ export interface AniListMedia {
coverImage?: AniListCoverImage | null;
description?: string | null;
genres?: string[] | null;
trailer?: { id?: string | null; site?: string | null } | null;
nextAiringEpisode?: {
airingAt?: number | null;
episode?: number | null;
@ -640,6 +722,13 @@ export interface MediaSearchResult {
releaseDate: string;
url: string;
seasons: Season[];
posterUrl?: string;
/** '' = unresolved, 'none' = no trailer, or a YouTube watch URL. */
trailerUrl?: string;
/** Only set by detail lookups: normalized name list or ['none'] (see CREDITS_NONE). */
director?: string[];
/** Only set by detail lookups: normalized name list (capped at MAX_CAST) or ['none']. */
cast?: string[];
}
// ── Jikan API shapes ─────────────────────────────────────────────────────────
@ -652,6 +741,10 @@ export interface JikanAnime {
duration: string | null;
aired: { from: string | null } | null;
url: string;
images?: { jpg?: { image_url?: string | null; small_image_url?: string | null } | null } | null;
// Jikan often leaves youtube_id / url null while embed_url still carries the id,
// so all three are parsed defensively (see ApiService.extractYoutubeId).
trailer?: { youtube_id?: string | null; url?: string | null; embed_url?: string | null } | null;
}
// ── OMDb API shapes ───────────────────────────────────────────────────────────
@ -681,6 +774,10 @@ export interface OmdbDetailResponse {
Error?: string;
imdbRating?: string;
imdbVotes?: string;
/** Comma-separated director name(s), or 'N/A'. */
Director?: string;
/** Comma-separated actor names (OMDb returns at most 4), or 'N/A'. */
Actors?: string;
}
export interface OmdbSeasonResponse {
@ -697,18 +794,40 @@ export interface TmdbSearchItem {
name?: string;
release_date?: string;
first_air_date?: string;
poster_path?: string | null;
}
export interface TmdbSearchResponse {
results?: TmdbSearchItem[];
}
export interface TmdbVideo {
key: string;
site: string;
type: string;
official?: boolean;
name?: string;
}
export interface TmdbVideosResult {
results?: TmdbVideo[];
}
export interface TmdbCreditsResult {
cast?: Array<{ name?: string }>;
crew?: Array<{ name?: string; job?: string }>;
}
export interface TmdbMovieDetail {
id: number;
title: string;
runtime?: number;
release_date?: string;
imdb_id?: string;
/** Present only when the detail call uses append_to_response=videos. */
videos?: TmdbVideosResult;
/** Present only when the detail call uses append_to_response=credits. */
credits?: TmdbCreditsResult;
}
export interface TmdbTvSeason {
@ -724,6 +843,10 @@ export interface TmdbTvDetail {
first_air_date?: string;
number_of_episodes?: number;
seasons?: TmdbTvSeason[];
/** Present only when the detail call uses append_to_response=videos. */
videos?: TmdbVideosResult;
/** Present only when the detail call uses append_to_response=credits. */
credits?: TmdbCreditsResult;
}
export interface TmdbExternalIds {

View file

@ -25,6 +25,45 @@
padding: 8px 10px 0;
border-bottom: 0.5px solid var(--background-modifier-border);
flex-shrink: 0;
/* Clip the button row so it can never extend past the panel and let the whole
view be dragged sideways ("loose sheet" pan). On mobile the tabs go icon-only
(below) so nothing is actually clipped. */
overflow-x: hidden;
}
/* Mobile: icon-only tabs
Seven text labels overflow a phone-width panel; render Lucide icons instead
and let them share the row equally so all seven fit with no horizontal scroll. */
.wl-view.wl-mobile .wl-tab-bar {
gap: 0;
padding: 6px 4px 0;
}
.wl-tab-btn-icon {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 7px 0;
position: relative;
}
.wl-tab-btn-icon .svg-icon {
width: 20px;
height: 20px;
}
/* Count badge in icon mode: a small purple dot on the icon (vs. "(N)" on desktop). */
.wl-tab-btn-icon.wl-tab-has-badge::after {
content: '';
position: absolute;
top: 3px;
right: calc(50% - 13px);
width: 7px;
height: 7px;
border-radius: 50%;
background-color: #7F77DD;
}
.wl-tab-btn {
@ -54,6 +93,11 @@
flex: 1 1 0%;
min-height: 0;
overflow-y: auto;
/* Clip horizontal overflow so the view can't be panned sideways like a loose
sheet. Without this, overflow-y:auto + the default overflow-x:visible forces
overflow-x to compute to `auto` (per the CSS overflow spec), turning the
scroller into a horizontal scroll container on mobile. */
overflow-x: hidden;
padding: 12px 10px;
}
@ -79,11 +123,21 @@
/* ── Rings grid ─────────────────────────────────────────── */
.wl-rings-grid {
display: grid;
/* Desktop: fixed 3-column layout (original look). Mobile overrides this with a
container-responsive auto-fit grid below. */
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
/* Mobile: reflow on the actual panel width (not the viewport). Wide enough panels
show up to 3 columns at full ring size and degrade to 2 then 1 as it narrows.
min(110px, 100%) caps the track at the container width so a panel narrower than
one card can never force horizontal overflow. */
.wl-view.wl-mobile .wl-rings-grid {
grid-template-columns: repeat(auto-fit, minmax(min(110px, 100%), 1fr));
}
/* ── Unified Total / Time card ──────────────────────────── */
/* The two vertical dividers are rendered as 1px grid gaps that reveal the
card's background colour. A 1px child border-left did not paint reliably at the
@ -147,6 +201,8 @@
align-items: center;
gap: 4px;
position: relative;
/* Let the grid track shrink below the card's intrinsic content width. */
min-width: 0;
}
/* Accent ring: anchored to the SVG circle rather than the whole card.
@ -169,7 +225,11 @@
.wl-ring-svg {
width: var(--wl-ring-size);
height: var(--wl-ring-size);
/* Scale down (keeping the square aspect) if the cell is narrower than the
gauge, so a column can drop below 110px without overflowing. */
max-width: 100%;
height: auto;
aspect-ratio: 1 / 1;
overflow: visible;
}
@ -988,6 +1048,36 @@
flex-shrink: 0;
}
/* Two labelled fields sharing one row, with fixed-width inputs anchored into
two columns (rather than stretched to 50%) to save vertical space. */
.wl-modal-pair-cell {
display: flex;
align-items: center;
gap: 8px;
}
/* Right group anchors to the row's right edge, leaving empty space in the
middle, so its input's right edge meets the full-width fields' right edge. */
.wl-modal-pair-cell-right {
margin-left: auto;
}
/* Left cell keeps the standard 110px label so its input's left edge lines up
with the full-width (Title) input; the right cell's label is natural width. */
.wl-modal-pair-cell-right .wl-modal-label {
width: auto;
}
/* All four paired fields (two number inputs + two selects) share one fixed,
narrow width only their left/right edges align with the full-width fields;
they intentionally do not span the row. */
.wl-modal-pair-cell .wl-modal-input,
.wl-modal-pair-cell .wl-select {
flex: 0 0 auto;
width: 110px;
min-width: 0;
}
.wl-modal-input {
flex: 1;
padding: 5px 8px;
@ -1059,7 +1149,10 @@
}
.wl-modal-results {
max-height: 200px;
/* Grown by ~2 reclaimed form rows (Episodes/duration + Status/priority now
pair onto single rows) so the results list fills the freed vertical space
while the overall modal height stays the same. */
max-height: 272px;
overflow-y: auto;
border: 0.5px solid var(--background-modifier-border);
border-radius: 5px;
@ -1073,10 +1166,50 @@
transition: background 0.1s;
}
/* Rows that carry a cover thumbnail lay out as a flex row (thumb · text). */
.wl-result-item.wl-has-thumb {
display: flex;
align-items: center;
gap: 10px;
}
.wl-result-item:last-child {
border-bottom: none;
}
.wl-result-text {
min-width: 0;
flex: 1 1 auto;
}
/* Shared cover thumbnail for add-flow search-result rows (poster 2:3). */
.wl-result-thumb {
position: relative;
flex: 0 0 auto;
width: 40px;
height: 60px;
border-radius: 3px;
overflow: hidden;
background: var(--background-modifier-border);
display: flex;
align-items: center;
justify-content: center;
}
.wl-result-thumb-icon {
font-size: 18px;
opacity: 0.6;
line-height: 1;
}
.wl-result-thumb-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.wl-result-item:hover,
.wl-result-item.is-selected,
.wl-result-item.wl-result-item-focused {
@ -1147,6 +1280,71 @@
background: var(--background-modifier-hover);
}
.wl-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.wl-btn:disabled:hover {
background: var(--background-secondary);
}
/* ── Custom Lists import modal ─────────────────────────── */
.wl-import-textarea {
min-height: 140px;
margin: 8px 0;
font-family: var(--font-monospace);
}
.wl-import-header-toggle {
display: flex;
align-items: center;
gap: 6px;
margin: 4px 0 10px;
}
.wl-import-header-toggle label {
font-size: var(--font-ui-small);
color: var(--text-muted);
cursor: pointer;
}
.wl-import-header-checkbox {
cursor: pointer;
}
.wl-import-preview {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.wl-import-counts {
font-size: var(--font-ui-small);
color: var(--text-muted);
}
.wl-import-banner {
padding: 6px 10px;
border-radius: 4px;
background: color-mix(in srgb, var(--background-secondary) 80%, #BA7517 20%);
border: 0.5px solid color-mix(in srgb, var(--background-modifier-border) 60%, #BA7517 40%);
font-size: 11px;
color: var(--text-muted);
line-height: 1.5;
}
.wl-import-banner-error {
padding: 6px 10px;
border-radius: 4px;
background: color-mix(in srgb, var(--background-modifier-error) 50%, transparent 50%);
border: 0.5px solid color-mix(in srgb, var(--background-modifier-border) 50%, #E24B4A 50%);
font-size: 11px;
color: var(--text-normal);
line-height: 1.5;
}
.wl-btn-primary {
/* green color scheme — matches .wl-btn-success */
background: transparent;
@ -1429,15 +1627,12 @@
}
@media (max-width: 400px) {
/* Tighten the rings-grid gap on very small screens; column count and ring
size are now handled by the auto-fit grid + scaling SVG above. */
.wl-rings-grid {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.wl-ring-item {
--wl-ring-size: 90px;
}
.wl-table-header-row,
.wl-row {
grid-template-columns: 1fr 56px 20px;
@ -1941,6 +2136,13 @@
min-width: 0;
}
/* Toolbar action-button wrapper display: contents so the buttons still lay
out as direct flex items of the controls row, while ListTab can rebuild them
in isolation (selection-mode toggles) without touching the search input. */
.wl-toolbar-actions {
display: contents;
}
/* Add buttons pinned to the far right of the toolbar row */
.wl-header-controls-right {
display: flex;
@ -3248,6 +3450,98 @@
border-bottom: 0.5px solid var(--background-modifier-border);
}
/* ── Column resize (Excel-style) ──────────────────────────── */
/* Resizable mode: explicit column widths, no flex redistribution, horizontal scroll. */
.wl-cl-table-resizable {
width: max-content;
min-width: 100%;
/* Let the scroll container (.wl-cl-table-wrap) own the horizontal scroll so
the sticky actions cell clamps to the viewport edge, not the table edge. */
overflow: visible;
}
.wl-cl-table-resizable .wl-cl-tr {
min-width: 100%;
}
.wl-cl-table-resizable .wl-cl-th.wl-cl-col-fixed,
.wl-cl-table-resizable .wl-cl-td.wl-cl-col-fixed {
flex: 0 0 var(--wl-cl-cw);
}
/* ── Sticky row actions (resizable mode only) ─────────────── */
/* Keep the actions cell pinned to the visible right edge while the table
scrolls horizontally. Opaque backgrounds (base colour + translucent state
overlay) match the row so scrolled cell content does not show through. */
.wl-cl-table-resizable .wl-cl-td-actions {
/* margin-left:auto pushes the cell flush to the right edge when the table fits
(free space to the right); it resolves to 0 when the table overflows, letting
sticky pin it to the visible right edge. The opaque background (matched to the
row state below) means revealed controls never let cell content bleed through
the reveal animates only the inner controls' opacity, not this cell. */
margin-left: auto;
position: sticky;
right: 0;
z-index: 1;
background-color: var(--background-primary);
}
.wl-cl-table-resizable .wl-cl-th-actions {
margin-left: auto;
position: sticky;
right: 0;
z-index: 3;
background-color: var(--background-secondary);
}
.wl-cl-table-resizable .wl-cl-tr-body:hover .wl-cl-td-actions {
background-image: linear-gradient(var(--background-modifier-hover), var(--background-modifier-hover));
}
.wl-cl-table-resizable .wl-cl-tr-body.wl-cl-tr-checked .wl-cl-td-actions,
.wl-cl-table-resizable .wl-cl-tr-body.wl-cl-tr-checked:hover .wl-cl-td-actions {
background-image: linear-gradient(
color-mix(in srgb, var(--color-green) 5%, transparent),
color-mix(in srgb, var(--color-green) 5%, transparent)
);
}
.wl-cl-th-resizable {
position: relative;
}
.wl-cl-col-resize {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 100%;
cursor: col-resize;
z-index: 2;
}
/* Always-visible quiet separator; brighter + thicker on hover to signal resize. */
.wl-cl-col-resize::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 6px;
width: 1px;
background: var(--background-modifier-border);
transition: background 0.12s, width 0.12s;
}
.wl-cl-col-resize:hover::after {
width: 2px;
background: var(--interactive-accent);
}
body.wl-cl-col-resizing {
cursor: col-resize;
user-select: none;
}
.wl-cl-tr {
display: flex;
align-items: stretch;
@ -4396,6 +4690,9 @@
max-width: 28px;
flex-shrink: 0;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
}
.wl-cl-td-tick {
@ -4450,6 +4747,11 @@
position: relative;
width: 100%;
overflow-y: auto;
/* Reserve the scrollbar gutter so clientWidth (which the virtual-scroll math
reads to size cards) doesn't change when the scrollbar appears otherwise
the cards would re-layout to a new width/height the first time content
overflows, a one-off layout shift. */
scrollbar-gutter: stable;
/* Fill remaining vertical space inside the watchlist container. */
flex: 1 1 auto;
min-height: 0;
@ -4467,15 +4769,28 @@
right: 0;
display: grid;
grid-template-columns: repeat(var(--wl-cards-cols, 1), 1fr);
/* Pin every row to the exact height the virtual-scroll math uses for the
spacer/padding offset. Without this, auto rows size to content and a card
whose intrinsic height exceeds --wl-card-height would stretch the whole
row, desyncing rendered height from reserved height (the CLS source). */
grid-auto-rows: var(--wl-card-height, auto);
align-content: start;
gap: 12px;
/* The virtual-scroll row offset is an inline padding-top set by ListTab
deliberately NOT transform: translateY, which made Chromium's layout-shift
tracker report a row of stationary cards as shifted on every row advance. */
padding: 0;
box-sizing: border-box;
will-change: transform;
}
.wl-cards-grid.wl-cards-grid-virtual .wl-card {
width: 100%;
height: var(--wl-card-height, auto);
/* --wl-card-height is the single source of truth for card height. Explicitly
cancel the base .wl-card aspect-ratio:2/3 here: in the grid it would give the
card an intrinsic height of trackWidth*1.5, which (when the real 1fr track is
wider than the computed cardWidth) overflows the reserved row and stretches
the card after layout shifting the absolute poster (the logged CLS). */
aspect-ratio: auto;
}
@ -4490,7 +4805,6 @@
.wl-detail-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
@ -4500,9 +4814,57 @@
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0;
}
/* Cover box: in-flow aspect-ratio slot (reserves its space from first paint,
same as ReadingTab's cover). Placeholder + poster are stacked absolutely and
swapped by opacity via .has-poster so revealing the image causes no shift. */
.wl-detail-cover {
position: relative;
width: 96px;
aspect-ratio: 2 / 3;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
background: var(--background-secondary);
}
.wl-detail-cover-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.6rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
}
.wl-detail-cover-placeholder.is-loading {
animation: wl-poster-pulse 1.5s ease-in-out infinite;
}
.wl-detail-cover-placeholder span {
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.wl-detail-cover-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.2s ease;
}
.wl-detail-cover.has-poster .wl-detail-cover-img {
opacity: 1;
}
.wl-detail-title {
margin: 0;
font-size: 1.3rem;
@ -4530,6 +4892,56 @@
border-radius: 6px;
}
/* Narrow widths: same font, tighter item gap + container padding so the
4-block bar (left / watched / episodes / progress) fits without clipping. */
@media (max-width: 600px) {
.wl-detail-stats-box {
gap: 7px;
padding: 8px 7px;
}
}
/* ── Credits (director / cast) rows — detail modal + List expanded row ────── */
.wl-detail-credits {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 6px;
}
.wl-acc-credits {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 2px;
}
.wl-credits-row {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
min-width: 0;
}
.wl-credits-label {
font-weight: 600;
margin-right: 6px;
}
.wl-credits-label::after {
content: ':';
}
.wl-credits-name.is-clickable {
cursor: pointer;
color: var(--text-normal);
}
.wl-credits-name.is-clickable:hover {
color: var(--text-accent, var(--interactive-accent));
text-decoration: underline;
}
.wl-detail-episodes {
display: flex;
flex-direction: column;
@ -4601,6 +5013,24 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Selected card in selection mode border only (no background tint), in the
theme accent used by the Watched checkbox. Drawn as an inset outline rather
than a border: outlines paint above the absolutely-positioned poster, follow
the border-radius, and never change the card's layout box. */
/* Drawn as an ::after overlay, not an outline/border on the card itself: the
absolutely-positioned poster paints above a negative-offset outline, leaving
only the ~1px sliver outside the padding box visible. The overlay is the
card's last paint-order child, so the full border width shows over the poster
without affecting layout or intercepting clicks. */
.wl-card.wl-card-selected::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid var(--wl-watched-color);
border-radius: inherit;
pointer-events: none;
}
.wl-card-poster-placeholder {
position: absolute;
inset: 0;
@ -4735,16 +5165,20 @@
width: 100%;
height: 100%;
object-fit: cover;
display: none;
}
/* Poster loaded: swap the letter placeholder for the image. */
.wl-card.has-poster .wl-card-poster {
/* display:block from first paint so the absolute inset:0 box is reserved
before the image data arrives; the unloaded poster is hidden with opacity
(which keeps the box) and fades in on load over the letter placeholder.
Note: the scroll-CLS logged against this img was NOT the reveal it was
the grid's translateY offset (see .wl-cards-grid-virtual / ListTab). */
display: block;
opacity: 0;
transition: opacity 0.2s ease;
}
.wl-card.has-poster .wl-card-poster-placeholder {
display: none;
/* Poster loaded: fade the image in on top of the letter placeholder (which
stays behind it). Opacity not display so the swap causes zero layout shift. */
.wl-card.has-poster .wl-card-poster {
opacity: 1;
}
.wl-card-overlay {
@ -4890,6 +5324,50 @@
pointer-events: none;
}
/* Trailer control at the tail of the rating row (rating badge → refresh → YouTube). */
.wl-trailer-control {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.wl-trailer-btn {
/* Wide clickable frame (~3× the icon) with the YouTube glyph centred at its
natural proportions only the frame widens, never the icon. */
min-width: 66px;
padding: 2px 20px;
margin-left: 8px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: #FF0000; /* Permanent red, not hover-only. */
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
box-shadow: none;
}
.wl-trailer-btn:hover {
color: #CC0000; /* Discreet darker-red + subtle wash so it still reads as clickable. */
background: var(--background-modifier-hover);
}
.wl-trailer-btn .svg-icon {
width: 22px;
height: 22px;
}
.wl-trailer-refresh {
margin-left: 8px;
}
.wl-trailer-refresh .svg-icon {
width: 14px;
height: 14px;
}
@keyframes wl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@ -5248,6 +5726,11 @@
}
.wl-reading-card-cover-img {
/* Absolutely fill the 2:3 cover box so a lazily-loaded cover image causes ZERO
layout shift the box size is fixed by .wl-reading-card-cover's aspect-ratio
before the image arrives, and the in-flow icon fallback stays centred. */
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
@ -5564,12 +6047,20 @@
}
.wl-reading-lookup-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
border: 1px solid transparent;
}
.wl-reading-lookup-item-text {
min-width: 0;
flex: 1 1 auto;
}
.wl-reading-status-rating-row {
display: flex;
flex-direction: row;
@ -6065,6 +6556,9 @@
height: 8px;
border-radius: 50%;
flex-shrink: 0;
/* Default (neutral) dot for options without an assigned colour. Status
options always set an inline background-color, which overrides this. */
background: var(--background-modifier-border);
}
/* ── Editable date cells ─────────────────────────────── */
@ -6350,6 +6844,34 @@
border-color: rgba(0, 0, 0, 0.35);
}
/* Per-list tab colour row in the Table settings modal. */
.wl-editcols-tabcolor-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.wl-editcols-tabcolor-label {
font-size: 13px;
font-weight: 600;
color: var(--text-normal);
}
.wl-editcols-tabcolor-dot.is-unset,
.wl-editcol-opt-color-dot.is-unset {
background: repeating-conic-gradient(
var(--background-modifier-border) 0% 25%,
var(--background-primary) 0% 50%
) 0 / 8px 8px;
}
.wl-editcol-opt-color-dot {
flex: 0 0 16px;
width: 16px;
height: 16px;
}
.wl-reading-col-palette {
position: fixed;
display: grid;
@ -6382,6 +6904,50 @@
outline-offset: 1px;
}
/* Custom full-spectrum swatch in the shared colour picker. */
.wl-color-custom-swatch {
position: relative;
overflow: hidden;
}
.wl-color-custom-swatch.wl-color-custom-rainbow {
background: conic-gradient(
from 90deg,
#f43f5e, #f59e0b, #facc15, #22c55e, #06b6d4, #3b82f6, #8b5cf6, #f43f5e
);
border-color: var(--background-modifier-border);
}
.wl-color-custom-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: none;
opacity: 0;
cursor: pointer;
}
/* Clear / "no colour" swatch — checkerboard with a diagonal slash. */
.wl-color-clear-swatch {
border-color: var(--background-modifier-border);
background:
linear-gradient(
to top left,
transparent 0%,
transparent calc(50% - 1px),
var(--text-error, #e5484d) 50%,
transparent calc(50% + 1px),
transparent 100%
),
repeating-conic-gradient(
var(--background-modifier-border) 0% 25%,
var(--background-primary) 0% 50%
) 0 / 10px 10px;
}
/* ── Manage columns modal ──────────────────────────────────── */
.wl-reading-manage-cols-desc {
font-size: 12px;

View file

@ -1,4 +1,5 @@
{
"2.2.0": "1.4.0",
"2.1.0": "1.4.0",
"2.0.0": "1.4.0",
"1.1.0": "1.4.0",