obsidian-book-tracker/src/ui/code-blocks/ReadingCalendarCodeBlockVie...

403 lines
7.8 KiB
Svelte

<script lang="ts">
import { parseYaml } from "obsidian";
import { ReadingCalendarSettingsSchema } from "./ReadingCalendarCodeBlock";
import type { SvelteCodeBlockProps } from "./SvelteCodeBlockRenderer";
import {
createSettings,
setSettingsContext,
} from "@ui/stores/settings.svelte";
import {
createMetadata,
setMetadataContext,
} from "@ui/stores/metadata.svelte";
import {
createReadingLog,
setReadingLogContext,
} from "@ui/stores/reading-log.svelte";
import { ArrowLeft, ArrowRight } from "lucide-svelte";
import { onMount } from "svelte";
const { plugin, source }: SvelteCodeBlockProps = $props();
const settings = ReadingCalendarSettingsSchema.parse(parseYaml(source));
const settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
const metadataStore = createMetadata(plugin, {
statusFilter: null,
});
setMetadataContext(metadataStore);
const readingLog = createReadingLog(plugin.readingLog);
setReadingLogContext(readingLog);
let year = $state(new Date().getFullYear());
let month = $state(new Date().getMonth());
$effect(() => {
readingLog.filterYear = year;
readingLog.filterMonth = month + 1;
});
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const daysOfWeek = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
// @ts-expect-error Moment is provided by Obsidian
let today = $state(moment());
function msUntilMidnight() {
// @ts-expect-error Moment is provided by Obsidian
return moment().endOf("day").diff(today, "milliseconds");
}
function updateToday() {
setTimeout(() => {
// @ts-expect-error Moment is provided by Obsidian
today = moment();
updateToday();
}, msUntilMidnight() + 1000);
}
onMount(() => {
updateToday();
});
const weeks = $derived.by(() => {
// @ts-expect-error Moment is provided by Obsidian
const firstDay = moment()
.year(year)
.month(month)
.startOf("month")
.startOf("week");
// @ts-expect-error Moment is provided by Obsidian
const lastDay = moment()
.year(year)
.month(month)
.endOf("month")
.endOf("week");
const weeks = [];
let currentDay = firstDay.clone();
while (currentDay.isBefore(lastDay)) {
const week = [];
for (let i = 0; i < 7; i++) {
week.push(currentDay.clone());
currentDay.add(1, "day");
}
weeks.push(week);
}
return weeks;
});
interface CoverData {
src: string;
alt: string;
}
interface BookMapItem {
totalPagesRead: number;
covers: CoverData[];
}
const bookMap = $derived.by(() => {
const bookMap = new Map<number, BookMapItem>();
for (const item of readingLog.entries) {
const key = item.createdAt.date();
let coverPath = metadataStore.metadata.find(
(entry) => entry.file.basename === item.book,
)?.frontmatter?.[settings.coverProperty];
coverPath = plugin.app.vault.getFileByPath(coverPath);
if (!coverPath) {
continue;
}
coverPath = plugin.app.vault.getResourcePath(coverPath);
const value = bookMap.get(key) ?? { totalPagesRead: 0, covers: [] };
value.totalPagesRead += item.pagesRead;
value.covers.push({ src: coverPath, alt: item.book });
bookMap.set(key, value);
}
return bookMap;
});
</script>
<div class="reading-calendar">
<div class="controls">
<div class="left">
<button
class="prev-month"
aria-label="Go to previous month"
onclick={() => {
if (month === 0) {
month = 11;
year--;
} else {
month--;
}
}}
>
<ArrowLeft />
</button>
<button
class="today"
aria-label="Go to the current month"
onclick={() => {
year = today.year();
month = today.month();
}}
>
Today
</button>
</div>
<h2>{monthNames[month]} {year}</h2>
<button
class="next-month"
aria-label="Go to next month"
onclick={() => {
if (month === 11) {
month = 0;
year++;
} else {
month++;
}
}}
>
<ArrowRight />
</button>
</div>
<table>
<thead>
<tr>
{#each daysOfWeek as day}
<th>{day}</th>
{/each}
</tr>
</thead>
<tbody>
{#each weeks as week}
<tr>
{#each week as day}
{@const isThisMonth = day.month() === month}
<td
class:is-today={day.isSame(today, "day")}
class:is-weekend={day.day() === 0 ||
day.day() === 6}
class:is-prev-month={day.month() ===
(month === 0 ? 11 : month - 1)}
class:is-next-month={day.month() ===
(month + 1) % 12}
>
<div class="header">
<span>{day.date()}</span>
{#if isThisMonth && bookMap.has(day.date())}
{@const data = bookMap.get(day.date())!}
<span class="total-pages-read">
Pages: {data.totalPagesRead}
</span>
{/if}
</div>
<div class="covers">
{#if isThisMonth && bookMap.has(day.date())}
{@const data = bookMap.get(day.date())!}
{#each data.covers as cover}
{#if cover}
<img
src={cover.src}
alt={cover.alt}
/>
{/if}
{/each}
{/if}
</div>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<style lang="scss">
$cell-padding: var(--size-4-2);
.reading-calendar {
display: flex;
flex-direction: column;
overflow: auto;
.controls {
min-width: 800px;
display: grid;
grid-template-columns: max-content 1fr max-content;
gap: var(--size-4-4);
align-items: center;
h2 {
text-align: center;
margin: 0;
}
.left {
display: flex;
gap: var(--size-2-2);
align-items: center;
}
}
}
table {
table-layout: fixed;
border-collapse: collapse;
min-width: 800px;
th {
padding: $cell-padding;
width: calc(100% / 7);
}
td {
padding: $cell-padding;
&.is-prev-month,
&.is-next-month {
color: var(--text-faint);
}
&.is-today {
color: var(--text-accent);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
.total-pages-read {
font-size: var(--font-smallest);
color: var(--text-muted);
}
}
.covers {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
img {
border-radius: var(--radius-l);
}
&:has(img:first-child:nth-last-child(1)) {
img {
position: absolute;
height: 100%;
width: 100%;
}
}
&:has(img:first-child:nth-last-child(2)) {
img:first-child {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(0 0, 100% 0, 0 100%);
}
img:last-child {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(100% 100%, 100% 0, 0 100%);
}
}
&:has(img:first-child:nth-last-child(3)) {
img:first-child {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(0 0, 100% 0, 100% 20%, 0 50%);
}
img:nth-child(2) {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(100% 80%, 100% 20%, 0 50%);
}
img:last-child {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(0 50%, 100% 80%, 100% 100%, 0% 100%);
}
}
&:has(img:first-child:nth-last-child(4)) {
img:first-child {
position: absolute;
height: 50%;
width: 50%;
top: 0;
left: 0;
}
img:nth-child(2) {
position: absolute;
height: 50%;
width: 50%;
top: 0;
right: 0;
}
img:nth-child(3) {
position: absolute;
height: 50%;
width: 50%;
bottom: 0;
left: 0;
}
img:last-child {
position: absolute;
height: 50%;
width: 50%;
bottom: 0;
right: 0;
}
}
}
}
}
</style>