mirror of
https://github.com/svm0n/datadeck.git
synced 2026-07-22 08:31:46 +00:00
Inline view (src/inline-view.ts): a csv-view code block renders an editable table/cards/kanban view of a CSV inside any note. InlineCardHost satisfies the renderers' duck-typed surface so renderTable/renderLibrary/renderKanbanGenre are reused unchanged; edits write back via vault.modify and re-sync off the modify event. Block directives: file/mode/height/collapse. - Cards/Kanban thumbnails: getImageCol() auto-detects Image/Cover/Poster/… columns; resolveImageSrc() handles vault paths, wikilinks, md-images, URLs. - Card title + context-menu "Open entry" now open the expander even without a notes column (NoteExpanderModal hides its notes section when absent), so notes-less files (e.g. applications trackers) are editable from cards. - collapse: directive + fileCfg.collapsedGroups: Cards groups collapse by default and remember manual toggles (full-page too). - Habit add-form toggles for 0/1 columns; pseudo-categorical columns (few distinct values) get option dropdowns in the add + edit forms via shared looksCategorical() helper. - Kanban: empty group value → "—" column (was dropped in default grouping); rows with blank/unknown status render ungrouped instead of vanishing while the column header still counted them. - Dark-mode table row hover: explicit text-tint overlay (was near-invisible). - Docs: csv-view syntax in README; architecture + open follow-ups in handoff. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
335 lines
15 KiB
TypeScript
335 lines
15 KiB
TypeScript
// Library (cards-by-genre) view renderer. Extracted from CardView; the members
|
|
// it reaches are public on CardView. Type-only import → no runtime cycle.
|
|
// Covered by test-view-smoke.mjs.
|
|
|
|
import type { CardView } from "../../main";
|
|
import { CSVRow, LibrarySort } from "../types";
|
|
import { formatRatingForDisplay, resolveImageSrc } from "../utils";
|
|
import { parseRating } from "./stats";
|
|
import { effectiveGroupCol } from "./kanban";
|
|
|
|
export function renderLibrary(view: CardView, container: HTMLElement): void {
|
|
// Same grouping column as the kanban (per-file pick → category → auto
|
|
// fallback), so Cards stays available on files without a category column.
|
|
const cc = effectiveGroupCol(view);
|
|
const sc = view.getStatusCol();
|
|
const titleCol = view.titleKey() ?? view.headers[0];
|
|
const authorCol = view.authorKey();
|
|
|
|
if (!cc) {
|
|
container.createEl("p", { text: `No groupable column found.`, cls: "csv-empty-state" });
|
|
return;
|
|
}
|
|
|
|
// Collect all genres
|
|
const allGenres = new Set<string>();
|
|
view.rows.forEach(row => {
|
|
const cats = (row[cc] ?? "").split(",").map(c => c.trim()).filter(Boolean);
|
|
cats.forEach(c => allGenres.add(c));
|
|
});
|
|
|
|
// Collect all statuses
|
|
const allStatuses = new Set<string>();
|
|
if (sc) {
|
|
view.rows.forEach(row => {
|
|
const status = (row[sc] ?? "").trim();
|
|
if (status) allStatuses.add(status);
|
|
});
|
|
}
|
|
|
|
// Filters bar
|
|
const filtersBar = container.createDiv({ cls: "csv-library-filters" });
|
|
|
|
// Status filter
|
|
const statusSelect = filtersBar.createEl("select", { cls: "csv-library-filter-select" });
|
|
statusSelect.createEl("option", { text: "All", value: "all" });
|
|
|
|
// Add common status filters. "yes" and "seen" cover the common
|
|
// Watched=yes / Seen=yes boolean patterns used by movie trackers.
|
|
const commonDone = ["watched", "read", "finished", "completed", "done", "yes", "seen"];
|
|
const commonInProgress = ["watching", "reading", "in progress", "in-progress"];
|
|
const hasDone = Array.from(allStatuses).some(s => commonDone.includes(s.toLowerCase()));
|
|
const hasInProgress = Array.from(allStatuses).some(s => commonInProgress.includes(s.toLowerCase()));
|
|
|
|
if (hasDone || hasInProgress) {
|
|
statusSelect.createEl("option", { text: "───────", value: "", attr: { disabled: "true" } });
|
|
if (hasDone) statusSelect.createEl("option", { text: "✓ Done", value: "__done__" });
|
|
if (hasInProgress) statusSelect.createEl("option", { text: "◐ In Progress", value: "__inprogress__" });
|
|
statusSelect.createEl("option", { text: "○ Not Started", value: "__notstarted__" });
|
|
}
|
|
|
|
if (allStatuses.size > 0) {
|
|
statusSelect.createEl("option", { text: "───────", value: "", attr: { disabled: "true" } });
|
|
Array.from(allStatuses).sort().forEach(s => {
|
|
statusSelect.createEl("option", { text: s, value: s });
|
|
});
|
|
}
|
|
statusSelect.value = view.libraryStatusFilter;
|
|
|
|
// Genre filter
|
|
const genreSelect = filtersBar.createEl("select", { cls: "csv-library-filter-select" });
|
|
genreSelect.createEl("option", { text: "All genres", value: "all" });
|
|
Array.from(allGenres).sort().forEach(g => {
|
|
genreSelect.createEl("option", { text: g, value: g });
|
|
});
|
|
genreSelect.value = view.libraryGenreFilter;
|
|
|
|
// Card-detail columns, resolved once per render (used by the sort selector
|
|
// and by the per-card field rendering below).
|
|
const yearCol = view.resolveCol(["Year", "year", "Date", "date"]);
|
|
const ratingCol = view.resolveCol(["Rating", "rating", "Score", "score", "Score /5", "Stars", "stars"]);
|
|
const themeCol = view.resolveCol(["Theme", "theme", "Tags", "tags", "Tag", "tag", "Mood", "mood"]);
|
|
|
|
// Sort selector — persisted per file. "Status" is the historical default
|
|
// (consumed → in-progress → backlog); the others only appear when the
|
|
// file actually has the column to sort by.
|
|
const sortKey: LibrarySort = view.fileCfg.librarySort ?? "status";
|
|
const sortSelect = filtersBar.createEl("select", { cls: "csv-library-filter-select" });
|
|
const sortOptions: [LibrarySort, string][] = [["status", "Sort: Status"], ["title", "Sort: Title"]];
|
|
if (ratingCol) sortOptions.push(["rating", "Sort: Rating"]);
|
|
if (yearCol) sortOptions.push(["year", "Sort: Year"]);
|
|
sortOptions.forEach(([val, label]) => {
|
|
const opt = sortSelect.createEl("option", { text: label, value: val });
|
|
if (val === sortKey) opt.selected = true;
|
|
});
|
|
sortSelect.addEventListener("change", () => {
|
|
const cfg = view.fileCfg;
|
|
cfg.librarySort = sortSelect.value === "status" ? undefined : (sortSelect.value as LibrarySort);
|
|
view.saveFileCfg(cfg);
|
|
view.renderView(true);
|
|
});
|
|
|
|
// Search lives in the toolbar (the 🔍 toggle on mobile, always-visible
|
|
// input on desktop). Library used to render its own search input here,
|
|
// duplicating the one in the toolbar — both wrote to the same
|
|
// view.searchQuery. Removed.
|
|
|
|
// Filter handlers
|
|
const applyFilters = () => {
|
|
view.libraryStatusFilter = statusSelect.value;
|
|
view.libraryGenreFilter = genreSelect.value;
|
|
view.renderView(true);
|
|
};
|
|
|
|
statusSelect.addEventListener("change", applyFilters);
|
|
genreSelect.addEventListener("change", applyFilters);
|
|
|
|
// Filter rows
|
|
let filtered = view.rows.filter(row => {
|
|
// Status filter
|
|
if (view.libraryStatusFilter !== "all" && sc) {
|
|
const rowStatus = (row[sc] ?? "").toLowerCase();
|
|
if (view.libraryStatusFilter === "__done__") {
|
|
if (!commonDone.includes(rowStatus)) return false;
|
|
} else if (view.libraryStatusFilter === "__inprogress__") {
|
|
if (!commonInProgress.includes(rowStatus)) return false;
|
|
} else if (view.libraryStatusFilter === "__notstarted__") {
|
|
if (commonDone.includes(rowStatus) || commonInProgress.includes(rowStatus)) return false;
|
|
} else {
|
|
if (rowStatus !== view.libraryStatusFilter.toLowerCase()) return false;
|
|
}
|
|
}
|
|
|
|
// Genre filter
|
|
if (view.libraryGenreFilter !== "all") {
|
|
const rowGenres = (row[cc] ?? "").split(",").map(c => c.trim().toLowerCase());
|
|
if (!rowGenres.includes(view.libraryGenreFilter.toLowerCase())) return false;
|
|
}
|
|
|
|
// Search filter — match any field, consistent with getFilteredRows()
|
|
// in the other views (title-only matching here meant "search by author"
|
|
// worked in kanban/table but silently failed in the library).
|
|
if (view.searchQuery.trim()) {
|
|
const q = view.searchQuery.toLowerCase().trim();
|
|
if (!view.headers.some(h => (row[h] ?? "").toLowerCase().includes(q))) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Result count
|
|
if (view.libraryStatusFilter !== "all" || view.libraryGenreFilter !== "all" || view.searchQuery.trim()) {
|
|
container.createDiv({
|
|
cls: "csv-library-result-count",
|
|
text: `Showing ${filtered.length} of ${view.rows.length} entries`
|
|
});
|
|
}
|
|
|
|
// Group by genre
|
|
const groups: Record<string, CSVRow[]> = {};
|
|
filtered.forEach(row => {
|
|
const cats = view.libraryGenreFilter !== "all"
|
|
? [view.libraryGenreFilter]
|
|
: (row[cc] ?? "Uncategorized").split(",").map(c => c.trim()).filter(Boolean);
|
|
if (cats.length === 0) cats.push("Uncategorized");
|
|
cats.forEach(cat => {
|
|
if (!groups[cat]) groups[cat] = [];
|
|
groups[cat].push(row);
|
|
});
|
|
});
|
|
|
|
// Render sections
|
|
const sectionsWrap = container.createDiv({ cls: "csv-library-sections" });
|
|
|
|
Object.keys(groups).sort().forEach(genre => {
|
|
const items = groups[genre];
|
|
const section = sectionsWrap.createEl("details", { cls: "csv-library-section" });
|
|
// Collapsed by default if this group is in the inline `collapse:` directive
|
|
// (view.collapsedGroups) OR the file's remembered collapsed set
|
|
// (fileCfg.collapsedGroups). Manual toggles below persist to the latter, so
|
|
// collapse works — and is remembered — in the full-page Cards view too.
|
|
const gKey = genre.toLowerCase();
|
|
const persisted = (view.fileCfg.collapsedGroups ?? []).map(s => s.toLowerCase());
|
|
section.open = !(view.collapsedGroups?.has(gKey) || persisted.includes(gKey));
|
|
section.addEventListener("toggle", () => {
|
|
const cfg = view.fileCfg;
|
|
const cur = (cfg.collapsedGroups ?? []).map(s => s.toLowerCase());
|
|
const set = new Set(cur);
|
|
if (section.open) set.delete(gKey); else set.add(gKey);
|
|
const next = Array.from(set);
|
|
// Skip the write when nothing changed — guards against the spurious
|
|
// toggle event fired by the initial programmatic `section.open` set, so
|
|
// a render doesn't rewrite data.json every time.
|
|
if (next.length === cur.length && next.every(x => cur.includes(x))) return;
|
|
cfg.collapsedGroups = next;
|
|
view.saveFileCfg(cfg);
|
|
});
|
|
|
|
const summary = section.createEl("summary", { cls: "csv-library-section-header" });
|
|
summary.innerHTML = `<span class="csv-library-arrow">▶</span> ${genre} <span class="csv-library-count">${items.length}</span>`;
|
|
|
|
const grid = section.createDiv({ cls: "csv-library-grid" });
|
|
|
|
// Section ordering, driven by the sort selector:
|
|
// status (default) — green-dotted (read/watched/finished) first, then
|
|
// in-progress, then backlog; alphabetical within each band. Surfacing
|
|
// what you've already done makes the section read as a library
|
|
// catalogue (consumed → backlog) rather than a todo list.
|
|
// title — plain alphabetical.
|
|
// rating — best first; unrated last.
|
|
// year — newest first; undated last.
|
|
const byTitle = (a: CSVRow, b: CSVRow) => (a[titleCol] ?? "").localeCompare(b[titleCol] ?? "");
|
|
// Descending compare where null (missing value) always sorts last.
|
|
const descNullsLast = (va: number | null, vb: number | null): number => {
|
|
if (va === null && vb === null) return 0;
|
|
if (va === null) return 1;
|
|
if (vb === null) return -1;
|
|
return vb - va;
|
|
};
|
|
items.sort((a, b) => {
|
|
if (sortKey === "title") return byTitle(a, b);
|
|
if (sortKey === "rating" && ratingCol) {
|
|
return descNullsLast(parseRating(a[ratingCol] ?? ""), parseRating(b[ratingCol] ?? "")) || byTitle(a, b);
|
|
}
|
|
if (sortKey === "year" && yearCol) {
|
|
const yearOf = (r: CSVRow): number | null => {
|
|
const m = (r[yearCol] ?? "").match(/\d{4}/);
|
|
return m ? parseInt(m[0], 10) : null;
|
|
};
|
|
return descNullsLast(yearOf(a), yearOf(b)) || byTitle(a, b);
|
|
}
|
|
if (sc) {
|
|
const statusA = (a[sc] ?? "").toLowerCase();
|
|
const statusB = (b[sc] ?? "").toLowerCase();
|
|
const doneA = commonDone.includes(statusA);
|
|
const doneB = commonDone.includes(statusB);
|
|
if (doneA !== doneB) return doneA ? -1 : 1;
|
|
const inProgressA = commonInProgress.includes(statusA);
|
|
const inProgressB = commonInProgress.includes(statusB);
|
|
if (inProgressA !== inProgressB) return inProgressA ? -1 : 1;
|
|
}
|
|
return byTitle(a, b);
|
|
});
|
|
|
|
// Resolve which extra columns to surface on each card.
|
|
// If the user picked cardFields in the per-file Columns modal, use that list verbatim.
|
|
// Otherwise auto-detect: author, year, rating, theme.
|
|
const autoFields = [authorCol, yearCol, ratingCol, themeCol].filter((c): c is string => !!c);
|
|
const cardFields = view.fileCfg.cardFields ?? autoFields;
|
|
const imageCol = view.getImageCol?.() ?? null;
|
|
|
|
items.forEach(row => {
|
|
const card = grid.createDiv({ cls: "csv-library-card" });
|
|
|
|
// Cover image (when an image column resolves to a usable src). Lazy so a
|
|
// genre section with many cards doesn't fetch every image up front.
|
|
if (imageCol) {
|
|
const src = resolveImageSrc(view.app, row[imageCol] ?? "", view.file?.path ?? "");
|
|
if (src) {
|
|
const img = card.createEl("img", { cls: "csv-library-card-img", attr: { src, loading: "lazy", alt: "" } });
|
|
img.addEventListener("error", () => img.remove()); // drop broken images quietly
|
|
}
|
|
}
|
|
|
|
// Title with green dot for "done"-style status (watched, read, finished, etc.)
|
|
const titleWrap = card.createDiv({ cls: "csv-library-card-title" });
|
|
if (sc) {
|
|
const status = (row[sc] ?? "").toLowerCase();
|
|
if (commonDone.includes(status)) {
|
|
titleWrap.createSpan({ cls: "csv-library-done-dot" });
|
|
}
|
|
}
|
|
titleWrap.createSpan({ text: row[titleCol] ?? "Untitled" });
|
|
|
|
// Walk cardFields in order, rendering each with the right element type.
|
|
// Rating → stars line; theme/tag/category aliases → pills; everything else → meta line.
|
|
const metaParts: string[] = [];
|
|
const themeFieldsForCard: string[] = [];
|
|
for (const col of cardFields) {
|
|
if (!col) continue;
|
|
const raw = String(row[col] ?? "").trim();
|
|
if (!raw) continue;
|
|
|
|
if (col === ratingCol) {
|
|
// Render rating as stars on its own line. Already-star data passes through.
|
|
const display = formatRatingForDisplay(raw, col);
|
|
if (display) card.createDiv({ cls: "csv-library-card-rating", text: display });
|
|
} else if (col === themeCol) {
|
|
// Comma-separated theme values render as multiple pills.
|
|
themeFieldsForCard.push(...raw.split(",").map(t => t.trim()).filter(Boolean));
|
|
} else if (col === yearCol) {
|
|
// Year — extract 4-digit if it's a full date.
|
|
const m = raw.match(/\d{4}/);
|
|
metaParts.push(m ? m[0] : raw);
|
|
} else {
|
|
metaParts.push(raw);
|
|
}
|
|
}
|
|
if (metaParts.length) {
|
|
card.createDiv({ cls: "csv-library-card-meta", text: metaParts.join(" · ") });
|
|
}
|
|
|
|
// Secondary genres render as extra tags when filtering by a single genre.
|
|
if (view.libraryGenreFilter !== "all") {
|
|
const otherGenres = (row[cc] ?? "").split(",").map(c => c.trim()).filter(c => c && c.toLowerCase() !== view.libraryGenreFilter.toLowerCase());
|
|
themeFieldsForCard.push(...otherGenres);
|
|
}
|
|
if (themeFieldsForCard.length) {
|
|
const tagsWrap = card.createDiv({ cls: "csv-library-card-tags" });
|
|
themeFieldsForCard.slice(0, 3).forEach(tag => {
|
|
tagsWrap.createSpan({ cls: "csv-library-card-tag", text: tag });
|
|
});
|
|
}
|
|
|
|
// Click to expand. Opens even without a notes column — the expander
|
|
// edits every structured field; its notes section just doesn't render.
|
|
card.addEventListener("click", () => {
|
|
view.openNoteExpander(row, view.getNotesCol() ?? "");
|
|
});
|
|
card.addEventListener("contextmenu", e => view.openRowContextMenu(row, e));
|
|
});
|
|
});
|
|
|
|
if (Object.keys(groups).length === 0) {
|
|
const empty = sectionsWrap.createDiv({ cls: "csv-empty-state" });
|
|
empty.createEl("p", { text: "No entries match your filters." });
|
|
const clearBtn = empty.createEl("button", { cls: "csv-clear-filters-btn", text: "Clear filters" });
|
|
clearBtn.addEventListener("click", () => {
|
|
view.libraryStatusFilter = "all";
|
|
view.libraryGenreFilter = "all";
|
|
view.searchQuery = "";
|
|
// Full re-render — the toolbar search input needs to reset too.
|
|
view.renderView();
|
|
});
|
|
}
|
|
}
|