generated from tpl/obsidian-sample-plugin
Add actions to reading log viewer
This commit is contained in:
parent
a2767f4595
commit
8ebed95fda
|
@ -3,6 +3,14 @@ import process from "process";
|
||||||
import builtins from "builtin-modules";
|
import builtins from "builtin-modules";
|
||||||
import esbuildSvelte from "esbuild-svelte";
|
import esbuildSvelte from "esbuild-svelte";
|
||||||
import { sveltePreprocess } from "svelte-preprocess";
|
import { sveltePreprocess } from "svelte-preprocess";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import dotenvExpand from "dotenv-expand";
|
||||||
|
import manifest from "./manifest.json" with { type: "json" };
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const env = dotenv.config();
|
||||||
|
dotenvExpand.expand(env);
|
||||||
|
|
||||||
const banner = `/*
|
const banner = `/*
|
||||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||||
|
@ -12,6 +20,21 @@ if you want to view the source, please visit the github repository of this plugi
|
||||||
|
|
||||||
const prod = process.argv[2] === "production";
|
const prod = process.argv[2] === "production";
|
||||||
|
|
||||||
|
let outDir = "dist";
|
||||||
|
if (!prod) {
|
||||||
|
if (!process.env.OBSIDIAN_PLUGIN_DIR) {
|
||||||
|
console.log(
|
||||||
|
"Set OBSIDIAN_PLUGIN_DIR in the .env file to plugin directory to copy files to."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
outDir = process.env.OBSIDIAN_PLUGIN_DIR + "/" + manifest.id + "-dev";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outDir[0] === "~") {
|
||||||
|
outDir = path.join(process.env.HOME, outDir.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
const context = await esbuild.context({
|
const context = await esbuild.context({
|
||||||
banner: {
|
banner: {
|
||||||
js: banner,
|
js: banner,
|
||||||
|
@ -21,11 +44,32 @@ const context = await esbuild.context({
|
||||||
plugins: [
|
plugins: [
|
||||||
esbuildSvelte({
|
esbuildSvelte({
|
||||||
preprocess: sveltePreprocess(),
|
preprocess: sveltePreprocess(),
|
||||||
compilerOptions: {
|
compilerOptions: { dev: !prod },
|
||||||
css: "injected",
|
|
||||||
dev: !prod,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
name: 'copy-plugin',
|
||||||
|
setup(build) {
|
||||||
|
build.onEnd(async () => {
|
||||||
|
try {
|
||||||
|
await fs.copyFile(new URL('./manifest.json', import.meta.url), path.resolve(outDir, 'manifest.json'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to rename file:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rename-plugin',
|
||||||
|
setup(build) {
|
||||||
|
build.onEnd(async () => {
|
||||||
|
try {
|
||||||
|
await fs.rename(path.resolve(outDir, 'main.css'), path.resolve(outDir, 'styles.css'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to rename file:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
],
|
],
|
||||||
external: [
|
external: [
|
||||||
"obsidian",
|
"obsidian",
|
||||||
|
@ -48,8 +92,8 @@ const context = await esbuild.context({
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
sourcemap: prod ? false : "inline",
|
sourcemap: prod ? false : "inline",
|
||||||
treeShaking: true,
|
treeShaking: true,
|
||||||
outfile: "main.js",
|
outdir: outDir,
|
||||||
minify: prod,
|
minify: prod
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prod) {
|
if (prod) {
|
||||||
|
|
11
package.json
11
package.json
|
@ -4,8 +4,8 @@
|
||||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch --mode=development",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "vite build",
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||||
"svelte-check": "svelte-check --tsconfig tsconfig.json"
|
"svelte-check": "svelte-check --tsconfig tsconfig.json"
|
||||||
},
|
},
|
||||||
|
@ -13,13 +13,10 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@guanghechen/rollup-plugin-copy": "^6.0.7",
|
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
|
||||||
"@types/node": "^24.0.6",
|
"@types/node": "^24.0.6",
|
||||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||||
"@typescript-eslint/parser": "5.29.0",
|
"@typescript-eslint/parser": "5.29.0",
|
||||||
"bits-ui": "^2.8.10",
|
|
||||||
"builtin-modules": "3.3.0",
|
"builtin-modules": "3.3.0",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
"dotenv-expand": "^12.0.2",
|
"dotenv-expand": "^12.0.2",
|
||||||
|
@ -29,12 +26,12 @@
|
||||||
"lucide-svelte": "^0.525.0",
|
"lucide-svelte": "^0.525.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"obsidian": "latest",
|
"obsidian": "latest",
|
||||||
|
"runed": "^0.29.1",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
"svelte": "^5.34.8",
|
"svelte": "^5.34.8",
|
||||||
"svelte-check": "^4.2.2",
|
"svelte-check": "^4.2.2",
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tslib": "2.4.0",
|
"tslib": "2.4.0",
|
||||||
"typescript": "5.0.4",
|
"typescript": "5.0.4"
|
||||||
"vite": "^6.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
738
pnpm-lock.yaml
738
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -1,46 +1,48 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { searchBooks, type SearchResult } from "@data-sources/goodreads";
|
import { searchBooks, type SearchResult } from "@data-sources/goodreads";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSearch: (results: SearchResult[]) => void;
|
onSearch: (query: string, results: SearchResult[]) => void;
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { onSearch, onError }: Props = $props();
|
||||||
onSearch,
|
|
||||||
onError
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
|
|
||||||
async function onkeydown(event: KeyboardEvent) {
|
async function onkeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
const results = await searchBooks(query);
|
const results = await searchBooks(query);
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
onError(new Error("No results found."));
|
onError(new Error("No results found."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSearch(results);
|
onSearch(query, results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError(error as Error);
|
onError(error as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="obt-goodreads-search">
|
<div class="obt-goodreads-search">
|
||||||
<h2>Goodreads Search</h2>
|
<h2>Goodreads Search</h2>
|
||||||
<input type="text" placeholder="Search for books..." bind:value={query} {onkeydown} />
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for books..."
|
||||||
|
bind:value={query}
|
||||||
|
{onkeydown}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.obt-goodreads-search {
|
.obt-goodreads-search {
|
||||||
padding-bottom: var(--size-4-4);
|
padding-bottom: var(--size-4-4);
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { unstable_RatingGroup as RatingGroup } from "bits-ui";
|
|
||||||
import { Star, StarHalf } from "lucide-svelte";
|
import { Star, StarHalf } from "lucide-svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -7,53 +6,104 @@
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxV = 5;
|
||||||
|
|
||||||
let { value = $bindable(), name }: Props = $props();
|
let { value = $bindable(), name }: Props = $props();
|
||||||
|
|
||||||
|
let ctrl: HTMLElement | null = $state(null);
|
||||||
|
let w = $state(0);
|
||||||
|
let h = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (ctrl) {
|
||||||
|
ctrl.style.width = `${w}px`;
|
||||||
|
ctrl.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let hovering = $state(false);
|
||||||
|
let valueHover = $state(0);
|
||||||
|
|
||||||
|
let displayVal = $derived(hovering ? valueHover : (value ?? 0));
|
||||||
|
let items = $derived.by(() => {
|
||||||
|
const full = Number.isInteger(displayVal);
|
||||||
|
return Array.from({ length: maxV }, (_, i) => i + 1).map((index) => ({
|
||||||
|
index,
|
||||||
|
state:
|
||||||
|
index <= Math.ceil(displayVal)
|
||||||
|
? full || index != Math.ceil(displayVal)
|
||||||
|
? "active"
|
||||||
|
: "partial"
|
||||||
|
: "inactive",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
console.log(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
function calcSliderPos(e: MouseEvent) {
|
||||||
|
return (e.offsetX / (e.target as HTMLElement).clientWidth) * maxV;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onclick() {
|
||||||
|
value = valueHover;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onmouseout() {
|
||||||
|
hovering = false;
|
||||||
|
valueHover = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onmousemove(e: MouseEvent) {
|
||||||
|
hovering = true;
|
||||||
|
valueHover = Math.ceil(calcSliderPos(e) * 2) / 2;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rating-input">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<RatingGroup.Root
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
{name}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
bind:value
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||||
min={0}
|
<div class="rating-input" {onclick} {onmouseout}>
|
||||||
max={5}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
allowHalf
|
<div class="ctrl" {onmousemove} bind:this={ctrl}></div>
|
||||||
class="rating-group"
|
<div class="cont m-1" bind:clientWidth={w} bind:clientHeight={h}>
|
||||||
>
|
{#each items as item (item.index)}
|
||||||
{#snippet children({ items })}
|
<div class="rating-item">
|
||||||
{#each items as item (item.index)}
|
{#if item.state === "inactive"}
|
||||||
<RatingGroup.Item index={item.index} class="rating-item">
|
<Star fill="var(--interactive-normal)" />
|
||||||
{#if item.state === "inactive"}
|
{:else if item.state === "active"}
|
||||||
<Star fill="var(--interactive-normal)" />
|
<Star fill="var(--interactive-accent)" />
|
||||||
{:else if item.state === "active"}
|
{:else if item.state === "partial"}
|
||||||
<Star fill="var(--interactive-accent)" />
|
<Star fill="var(--interactive-normal)" />
|
||||||
{:else if item.state === "partial"}
|
<StarHalf fill="var(--interactive-accent)" />
|
||||||
<Star fill="var(--interactive-normal)" />
|
{/if}
|
||||||
<StarHalf fill="var(--interactive-accent)" />
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
</RatingGroup.Item>
|
</div>
|
||||||
{/each}
|
|
||||||
{/snippet}
|
|
||||||
</RatingGroup.Root>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.rating-input {
|
.rating-input {
|
||||||
display: flex;
|
cursor: pointer;
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
.ctrl {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cont {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
:global(svg) {
|
:global(svg) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: var(--size-4-16);
|
width: 100%;
|
||||||
height: var(--size-4-16);
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.rating-group) {
|
.rating-item {
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.rating-item) {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: var(--size-4-16);
|
width: var(--size-4-16);
|
||||||
height: var(--size-4-16);
|
height: var(--size-4-16);
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ReadingLogEntry } from "@src/types";
|
||||||
|
interface Props {
|
||||||
|
entry: ReadingLogEntry;
|
||||||
|
onSubmit: (entry: ReadingLogEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { entry, onSubmit }: Props = $props();
|
||||||
|
|
||||||
|
let pagesRead = $state(entry.pagesRead);
|
||||||
|
let pagesReadTotal = $state(entry.pagesReadTotal);
|
||||||
|
let pagesRemaining = $state(entry.pagesRemaining);
|
||||||
|
|
||||||
|
// Source: https://github.com/sveltejs/svelte/discussions/14220#discussioncomment-11188219
|
||||||
|
function watch<T>(
|
||||||
|
getter: () => T,
|
||||||
|
effectCallback: (t: T | undefined) => void,
|
||||||
|
) {
|
||||||
|
let previous: T | undefined = undefined;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const current = getter(); // add $state.snapshot for deep reactivity
|
||||||
|
const cleanup = effectCallback(previous);
|
||||||
|
previous = current;
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => pagesRead,
|
||||||
|
(prev) => {
|
||||||
|
if (prev !== pagesRead && prev !== undefined) {
|
||||||
|
const diff = pagesRead - prev;
|
||||||
|
pagesReadTotal = pagesReadTotal + diff;
|
||||||
|
pagesRemaining = pagesRemaining - diff;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onsubmit(ev: SubmitEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
...entry,
|
||||||
|
pagesRead,
|
||||||
|
pagesReadTotal,
|
||||||
|
pagesRemaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="obt-reading-log-entry-editor">
|
||||||
|
<h2>Edit Reading Log Entry</h2>
|
||||||
|
<form {onsubmit}>
|
||||||
|
<div class="fields">
|
||||||
|
<label for="pagesRead">Pages Read</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="pagesRead"
|
||||||
|
id="pagesRead"
|
||||||
|
bind:value={pagesRead}
|
||||||
|
/>
|
||||||
|
<label for="pagesReadTotal">Pages Read Total</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="pagesReadTotal"
|
||||||
|
id="pagesReadTotal"
|
||||||
|
bind:value={pagesReadTotal}
|
||||||
|
/>
|
||||||
|
<label for="pagesRemaining">Pages Remaining</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="pagesRemaining"
|
||||||
|
id="pagesRemaining"
|
||||||
|
bind:value={pagesRemaining}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.obt-reading-log-entry-editor {
|
||||||
|
margin-bottom: var(--size-4-4);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: var(--size-4-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-4-4);
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: var(--size-4-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,6 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ReadingLog } from "@utils/storage";
|
import type { ReadingLog } from "@utils/storage";
|
||||||
|
import type { ReadingLogEntry } from "@src/types";
|
||||||
import type { App } from "obsidian";
|
import type { App } from "obsidian";
|
||||||
|
import { Edit, Trash } from "lucide-svelte";
|
||||||
|
import { ReadingLogEntryEditModal } from "@views/reading-log-entry-edit-modal";
|
||||||
|
|
||||||
const ALL_TIME = "ALL_TIME";
|
const ALL_TIME = "ALL_TIME";
|
||||||
|
|
||||||
|
@ -22,10 +25,23 @@
|
||||||
return moment(date).format("YYYY-MM-DD");
|
return moment(date).format("YYYY-MM-DD");
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = readingLog.getEntries();
|
let entries = $state(
|
||||||
const years = [
|
readingLog.getEntries().map((entry, id) => ({
|
||||||
|
...entry,
|
||||||
|
id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
entries = readingLog.getEntries().map((entry, id) => ({
|
||||||
|
...entry,
|
||||||
|
id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const years = $derived([
|
||||||
...new Set(entries.map((entry) => entry.createdAt.getFullYear())),
|
...new Set(entries.map((entry) => entry.createdAt.getFullYear())),
|
||||||
];
|
]);
|
||||||
|
|
||||||
let selectedYear = $state(new Date().getFullYear().toString());
|
let selectedYear = $state(new Date().getFullYear().toString());
|
||||||
const filterYear = $derived(
|
const filterYear = $derived(
|
||||||
|
@ -51,6 +67,22 @@
|
||||||
entry.createdAt.getMonth() === filterMonth - 1),
|
entry.createdAt.getMonth() === filterMonth - 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function editEntry(i: number, entry: ReadingLogEntry) {
|
||||||
|
const modal = new ReadingLogEntryEditModal(app, entry);
|
||||||
|
modal.once("submit", async (event) => {
|
||||||
|
console.log(i, event);
|
||||||
|
modal.close();
|
||||||
|
await readingLog.updateEntry(i, event.entry);
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
modal.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntry(i: number) {
|
||||||
|
await readingLog.spliceEntry(i);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="obt-reading-log-viewer">
|
<div class="obt-reading-log-viewer">
|
||||||
|
@ -85,6 +117,7 @@
|
||||||
<th class="book">Book</th>
|
<th class="book">Book</th>
|
||||||
<th class="pages-read">Pages Read</th>
|
<th class="pages-read">Pages Read</th>
|
||||||
<th class="percent-complete">Percent Complete</th>
|
<th class="percent-complete">Percent Complete</th>
|
||||||
|
<th class="actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -102,6 +135,16 @@
|
||||||
100,
|
100,
|
||||||
)}%
|
)}%
|
||||||
</td>
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button onclick={() => editEntry(entry.id, entry)}>
|
||||||
|
<Edit />
|
||||||
|
<span>Edit</span>
|
||||||
|
</button>
|
||||||
|
<button onclick={() => deleteEntry(entry.id)}>
|
||||||
|
<Trash />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -109,6 +152,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@use "../styles/utils";
|
||||||
|
|
||||||
.obt-reading-log-viewer {
|
.obt-reading-log-viewer {
|
||||||
.year-filter:has(> option.all-time:checked) + .month-filter {
|
.year-filter:has(> option.all-time:checked) + .month-filter {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -130,6 +175,10 @@
|
||||||
td.percent-complete {
|
td.percent-complete {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
td.actions span {
|
||||||
|
@include utils.visually-hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Source: https://gist.github.com/ffoodd/000b59f431e3e64e4ce1a24d5bb36034
|
||||||
|
|
||||||
|
@mixin visually-hidden {
|
||||||
|
border: 0 !important;
|
||||||
|
clip-path: inset(50%) !important; /* 2 */
|
||||||
|
height: 1px !important;
|
||||||
|
margin: -1px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 1px !important;
|
||||||
|
white-space: nowrap !important; /* 3 */
|
||||||
|
|
||||||
|
&:not(caption) {
|
||||||
|
position: absolute !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin visually-hidden-focusable {
|
||||||
|
&:not(:focus, :focus-within) {
|
||||||
|
@include visually-hidden;
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,3 +21,11 @@ export interface Book {
|
||||||
isbn: string;
|
isbn: string;
|
||||||
isbn13: string;
|
isbn13: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReadingLogEntry {
|
||||||
|
book: string;
|
||||||
|
pagesRead: number;
|
||||||
|
pagesReadTotal: number;
|
||||||
|
pagesRemaining: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
export abstract class Event {}
|
||||||
|
|
||||||
|
export type EventHandler<T> = (event: T) => void;
|
||||||
|
|
||||||
|
export type EventHandlerMap<TEventMap, T extends keyof TEventMap> = Record<
|
||||||
|
T,
|
||||||
|
EventHandler<TEventMap[T]>[]
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Constructor = new (...args: any[]) => {};
|
||||||
|
|
||||||
|
export function EventEmitter<TEventMap, TBase extends Constructor>(
|
||||||
|
Base: TBase
|
||||||
|
) {
|
||||||
|
return class extends Base {
|
||||||
|
private readonly listeners: EventHandlerMap<
|
||||||
|
TEventMap,
|
||||||
|
keyof TEventMap
|
||||||
|
> = {} as EventHandlerMap<TEventMap, keyof TEventMap>;
|
||||||
|
|
||||||
|
public on<T extends keyof TEventMap>(
|
||||||
|
type: T,
|
||||||
|
handler: EventHandler<TEventMap[T]>
|
||||||
|
) {
|
||||||
|
if (!this.listeners[type]) {
|
||||||
|
this.listeners[type] = [];
|
||||||
|
}
|
||||||
|
this.listeners[type].push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public off<T extends keyof TEventMap>(
|
||||||
|
type: T,
|
||||||
|
handler: EventHandler<TEventMap[T]>
|
||||||
|
) {
|
||||||
|
if (this.listeners[type]) {
|
||||||
|
this.listeners[type] = this.listeners[type].filter(
|
||||||
|
(h) => h !== handler
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public once<T extends keyof TEventMap>(
|
||||||
|
type: T,
|
||||||
|
handler: EventHandler<TEventMap[T]>
|
||||||
|
) {
|
||||||
|
const wrappedHandler = (event: TEventMap[T]) => {
|
||||||
|
handler(event);
|
||||||
|
this.off(type, wrappedHandler);
|
||||||
|
};
|
||||||
|
this.on(type, wrappedHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public emit<T extends keyof TEventMap>(type: T, event: TEventMap[T]) {
|
||||||
|
if (this.listeners[type]) {
|
||||||
|
this.listeners[type].forEach((handler) => handler(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import BookTrackerPlugin from "@src/main";
|
import BookTrackerPlugin from "@src/main";
|
||||||
|
import type { ReadingLogEntry } from "@src/types";
|
||||||
import { App } from "obsidian";
|
import { App } from "obsidian";
|
||||||
|
|
||||||
export class Storage {
|
export class Storage {
|
||||||
|
@ -33,14 +34,6 @@ export class Storage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReadingLogEntry {
|
|
||||||
readonly book: string;
|
|
||||||
readonly pagesRead: number;
|
|
||||||
readonly pagesReadTotal: number;
|
|
||||||
readonly pagesRemaining: number;
|
|
||||||
readonly createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ReadingLog {
|
export class ReadingLog {
|
||||||
private entries: ReadingLogEntry[] = [];
|
private entries: ReadingLogEntry[] = [];
|
||||||
|
|
||||||
|
@ -120,6 +113,17 @@ export class ReadingLog {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateEntry(i: number, entry: ReadingLogEntry): Promise<void> {
|
||||||
|
this.entries[i] = entry;
|
||||||
|
await this.storeEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async spliceEntry(i: number): Promise<ReadingLogEntry> {
|
||||||
|
const entry = this.entries.splice(i, 1);
|
||||||
|
await this.storeEntries();
|
||||||
|
return entry[0];
|
||||||
|
}
|
||||||
|
|
||||||
public async clearEntries(): Promise<void> {
|
public async clearEntries(): Promise<void> {
|
||||||
this.entries = [];
|
this.entries = [];
|
||||||
await this.storeEntries();
|
await this.storeEntries();
|
||||||
|
|
|
@ -1,29 +1,44 @@
|
||||||
import GoodreadsSearch from "@components/GoodreadsSearch.svelte";
|
import GoodreadsSearch from "@components/GoodreadsSearch.svelte";
|
||||||
import { type SearchResult } from "@data-sources/goodreads";
|
import { type SearchResult } from "@data-sources/goodreads";
|
||||||
|
import { Event, EventEmitter } from "@utils/event";
|
||||||
import { App, Modal, Notice } from "obsidian";
|
import { App, Modal, Notice } from "obsidian";
|
||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
|
|
||||||
export class GoodreadsSearchModal extends Modal {
|
export class SearchEvent extends Event {
|
||||||
private component: ReturnType<typeof GoodreadsSearch> | undefined;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
public readonly query: string,
|
||||||
private readonly onSearch: (error: any, results: SearchResult[]) => void
|
public readonly results: SearchResult[]
|
||||||
) {
|
) {
|
||||||
super(app);
|
super();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorEvent extends Event {
|
||||||
|
constructor(public readonly error: Error) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoodreadsSearchModalEventMap {
|
||||||
|
search: SearchEvent;
|
||||||
|
error: ErrorEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GoodreadsSearchModal extends EventEmitter<
|
||||||
|
GoodreadsSearchModalEventMap,
|
||||||
|
typeof Modal
|
||||||
|
>(Modal) {
|
||||||
|
private component: ReturnType<typeof GoodreadsSearch> | undefined;
|
||||||
|
|
||||||
onOpen() {
|
onOpen() {
|
||||||
this.component = mount(GoodreadsSearch, {
|
this.component = mount(GoodreadsSearch, {
|
||||||
target: this.contentEl,
|
target: this.contentEl,
|
||||||
props: {
|
props: {
|
||||||
onError(error) {
|
onError: (error: Error) => {
|
||||||
this.onSearch(error, []);
|
this.emit("error", new ErrorEvent(error));
|
||||||
this.close();
|
|
||||||
},
|
},
|
||||||
onSearch: (results: SearchResult[]) => {
|
onSearch: (query: string, results: SearchResult[]) => {
|
||||||
this.onSearch(null, results);
|
this.emit("search", new SearchEvent(query, results));
|
||||||
this.close();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -38,13 +53,14 @@ export class GoodreadsSearchModal extends Modal {
|
||||||
|
|
||||||
static createAndOpen(app: App): Promise<SearchResult[]> {
|
static createAndOpen(app: App): Promise<SearchResult[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const modal = new GoodreadsSearchModal(app, (error, results) => {
|
const modal = new GoodreadsSearchModal(app);
|
||||||
if (error) {
|
modal.once("search", (event: SearchEvent) => {
|
||||||
new Notice(`Error: ${error.message}`);
|
modal.close();
|
||||||
reject(error);
|
resolve(event.results);
|
||||||
} else {
|
});
|
||||||
resolve(results);
|
modal.once("error", (event: ErrorEvent) => {
|
||||||
}
|
modal.close();
|
||||||
|
reject(event.error);
|
||||||
});
|
});
|
||||||
modal.open();
|
modal.open();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,22 +1,30 @@
|
||||||
import Rating from "@components/Rating.svelte";
|
import Rating from "@components/Rating.svelte";
|
||||||
|
import { Event, EventEmitter } from "@utils/event";
|
||||||
import { App, Modal } from "obsidian";
|
import { App, Modal } from "obsidian";
|
||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
|
|
||||||
export class RatingModal extends Modal {
|
class SubmitEvent extends Event {
|
||||||
private component: ReturnType<typeof Rating> | undefined;
|
constructor(public readonly rating: number) {
|
||||||
|
super();
|
||||||
constructor(app: App, private readonly onSubmit: (rating: number) => void) {
|
|
||||||
super(app);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RatingModalEventMap {
|
||||||
|
submit: SubmitEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RatingModal extends EventEmitter<
|
||||||
|
RatingModalEventMap,
|
||||||
|
typeof Modal
|
||||||
|
>(Modal) {
|
||||||
|
private component: ReturnType<typeof Rating> | undefined;
|
||||||
|
|
||||||
onOpen(): void {
|
onOpen(): void {
|
||||||
this.component = mount(Rating, {
|
this.component = mount(Rating, {
|
||||||
target: this.contentEl,
|
target: this.contentEl,
|
||||||
props: {
|
props: {
|
||||||
onSubmit: (rating) => {
|
onSubmit: (rating: number) =>
|
||||||
this.onSubmit(rating);
|
this.emit("submit", new SubmitEvent(rating)),
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -30,8 +38,10 @@ export class RatingModal extends Modal {
|
||||||
|
|
||||||
static createAndOpen(app: App): Promise<number> {
|
static createAndOpen(app: App): Promise<number> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const modal = new RatingModal(app, (rating) => {
|
const modal = new RatingModal(app);
|
||||||
resolve(rating);
|
modal.once("submit", (event: SubmitEvent) => {
|
||||||
|
modal.close();
|
||||||
|
resolve(event.rating);
|
||||||
});
|
});
|
||||||
modal.open();
|
modal.open();
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import ReadingLogEntryEditor from "@components/ReadingLogEntryEditor.svelte";
|
||||||
|
import type { ReadingLogEntry } from "@src/types";
|
||||||
|
import { Event, EventEmitter } from "@utils/event";
|
||||||
|
import { App, Modal } from "obsidian";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
|
||||||
|
class SubmitEvent extends Event {
|
||||||
|
constructor(public readonly entry: ReadingLogEntry) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadingLogEntryEditModalEventMap {
|
||||||
|
submit: SubmitEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReadingLogEntryEditModal extends EventEmitter<
|
||||||
|
ReadingLogEntryEditModalEventMap,
|
||||||
|
typeof Modal
|
||||||
|
>(Modal) {
|
||||||
|
private component: ReturnType<typeof ReadingLogEntryEditor> | undefined;
|
||||||
|
|
||||||
|
constructor(app: App, private readonly entry: ReadingLogEntry) {
|
||||||
|
super(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
this.component = mount(ReadingLogEntryEditor, {
|
||||||
|
target: this.contentEl,
|
||||||
|
props: {
|
||||||
|
entry: this.entry,
|
||||||
|
onSubmit: (entry: ReadingLogEntry) =>
|
||||||
|
this.emit("submit", new SubmitEvent(entry)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
if (this.component) {
|
||||||
|
unmount(this.component);
|
||||||
|
this.component = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +1,25 @@
|
||||||
import ReadingProgress from "@components/ReadingProgress.svelte";
|
import ReadingProgress from "@components/ReadingProgress.svelte";
|
||||||
|
import { Event, EventEmitter } from "@utils/event";
|
||||||
import { App, Modal } from "obsidian";
|
import { App, Modal } from "obsidian";
|
||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
|
|
||||||
export class ReadingProgressModal extends Modal {
|
class SubmitEvent extends Event {
|
||||||
|
constructor(public readonly pageNumber: number) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadingProgressModalEventMap {
|
||||||
|
submit: SubmitEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReadingProgressModal extends EventEmitter<
|
||||||
|
ReadingProgressModalEventMap,
|
||||||
|
typeof Modal
|
||||||
|
>(Modal) {
|
||||||
private component: ReturnType<typeof ReadingProgress> | undefined;
|
private component: ReturnType<typeof ReadingProgress> | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(app: App, private readonly pageLength: number) {
|
||||||
app: App,
|
|
||||||
private readonly pageLength: number,
|
|
||||||
private readonly onSubmit: (pageNumber: number) => void
|
|
||||||
) {
|
|
||||||
super(app);
|
super(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,9 +28,8 @@ export class ReadingProgressModal extends Modal {
|
||||||
target: this.contentEl,
|
target: this.contentEl,
|
||||||
props: {
|
props: {
|
||||||
pageLength: this.pageLength,
|
pageLength: this.pageLength,
|
||||||
onSubmit: (pageNumber) => {
|
onSubmit: (pageNumber: number) => {
|
||||||
this.onSubmit(pageNumber);
|
this.emit("submit", new SubmitEvent(pageNumber));
|
||||||
this.close();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -35,13 +44,11 @@ export class ReadingProgressModal extends Modal {
|
||||||
|
|
||||||
static createAndOpen(app: App, pageLength: number): Promise<number> {
|
static createAndOpen(app: App, pageLength: number): Promise<number> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const modal = new ReadingProgressModal(
|
const modal = new ReadingProgressModal(app, pageLength);
|
||||||
app,
|
modal.once("submit", (event: SubmitEvent) => {
|
||||||
pageLength,
|
modal.close();
|
||||||
(pageNumber) => {
|
resolve(event.pageNumber);
|
||||||
resolve(pageNumber);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
modal.open();
|
modal.open();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,89 +0,0 @@
|
||||||
import { UserConfig, defineConfig } from "vite";
|
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
||||||
import copy from "@guanghechen/rollup-plugin-copy";
|
|
||||||
import manifest from "./manifest.json";
|
|
||||||
import path from "path";
|
|
||||||
import builtins from "builtin-modules";
|
|
||||||
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
import dotenvExpand from "dotenv-expand";
|
|
||||||
const env = dotenv.config();
|
|
||||||
dotenvExpand.expand(env);
|
|
||||||
|
|
||||||
export default defineConfig(async ({ mode }) => {
|
|
||||||
const { resolve } = path;
|
|
||||||
const prod = mode === "production";
|
|
||||||
|
|
||||||
let outDir = "dist";
|
|
||||||
if (!prod) {
|
|
||||||
if (!process.env.OBSIDIAN_PLUGIN_DIR) {
|
|
||||||
console.log(
|
|
||||||
"Set OBSIDIAN_PLUGIN_DIR in the .env file to plugin directory to copy files to."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
outDir =
|
|
||||||
process.env.OBSIDIAN_PLUGIN_DIR + "/" + manifest.id + "-dev";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outDir[0] === "~") {
|
|
||||||
outDir = path.join(process.env.HOME!, outDir.slice(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [
|
|
||||||
svelte(),
|
|
||||||
copy({
|
|
||||||
targets: [{ src: "manifest.json", dest: outDir }],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@components": resolve(__dirname, "./src/components"),
|
|
||||||
"@data-sources": resolve(__dirname, "./src/data-sources"),
|
|
||||||
"@settings": resolve(__dirname, "./src/settings"),
|
|
||||||
"@utils": resolve(__dirname, "./src/utils"),
|
|
||||||
"@views": resolve(__dirname, "./src/views"),
|
|
||||||
"@src": resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
lib: {
|
|
||||||
entry: resolve(__dirname, "src/main.ts"),
|
|
||||||
name: "main",
|
|
||||||
fileName: () => "main.js",
|
|
||||||
formats: ["cjs"],
|
|
||||||
},
|
|
||||||
minify: prod,
|
|
||||||
sourcemap: prod ? false : "inline",
|
|
||||||
cssCodeSplit: false,
|
|
||||||
emptyOutDir: false,
|
|
||||||
outDir,
|
|
||||||
rollupOptions: {
|
|
||||||
input: {
|
|
||||||
main: resolve(__dirname, "src/main.ts"),
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
entryFileNames: "main.js",
|
|
||||||
assetFileNames: "styles.css",
|
|
||||||
},
|
|
||||||
external: [
|
|
||||||
"obsidian",
|
|
||||||
"electron",
|
|
||||||
"@codemirror/autocomplete",
|
|
||||||
"@codemirror/collab",
|
|
||||||
"@codemirror/commands",
|
|
||||||
"@codemirror/language",
|
|
||||||
"@codemirror/lint",
|
|
||||||
"@codemirror/search",
|
|
||||||
"@codemirror/state",
|
|
||||||
"@codemirror/view",
|
|
||||||
"@lezer/common",
|
|
||||||
"@lezer/highlight",
|
|
||||||
"@lezer/lr",
|
|
||||||
...builtins,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as UserConfig;
|
|
||||||
});
|
|
Loading…
Reference in New Issue