generated from tpl/obsidian-sample-plugin
401 lines
7.8 KiB
Svelte
401 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, 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>
|