Testing
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/pdfs/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
653
css/style.css
Normal file
653
css/style.css
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
:root {
|
||||||
|
--paper: #f3ead3;
|
||||||
|
--paper-dark: #e8dcb8;
|
||||||
|
--ink: #1a1714;
|
||||||
|
--muted: #4a443c;
|
||||||
|
--rule: #1a1714;
|
||||||
|
--sidebar: 16.5rem;
|
||||||
|
--focus: #1a1714;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--paper: #1c1916;
|
||||||
|
--paper-dark: #141210;
|
||||||
|
--ink: #f3ead3;
|
||||||
|
--muted: #c9bda8;
|
||||||
|
--rule: #d4c6aa;
|
||||||
|
--focus: #f3ead3;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "Courier New", Courier, ui-monospace, monospace;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar) 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
border-right: 1px solid var(--rule);
|
||||||
|
background: var(--paper-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-head {
|
||||||
|
padding: 1rem 1rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-head h2 {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-family: Georgia, "Times New Roman", Times, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-picker select {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-actions button,
|
||||||
|
#remove-vault {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#remove-vault {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-heading {
|
||||||
|
padding: 0.7rem 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-row button[data-folder-action] {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item .count {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item.active {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item.active .count {
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item:hover:not(.active) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item.nested {
|
||||||
|
padding-left: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-path {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.75rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.9rem 1.15rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, "Times New Roman", Times, serif;
|
||||||
|
font-size: 1.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datestamp {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1 1 14rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="text"],
|
||||||
|
input[type="url"],
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"] {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
padding: 0.65rem 0.95rem;
|
||||||
|
border-right: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .value {
|
||||||
|
display: block;
|
||||||
|
font-family: Georgia, "Times New Roman", Times, serif;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 2fr;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters fieldset {
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--rule);
|
||||||
|
padding: 0.55rem 0.9rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters fieldset:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters legend {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters label {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0.2rem 0.85rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters input {
|
||||||
|
accent-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1.1rem 1.15rem 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
font-family: Georgia, "Times New Roman", Times, serif;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
padding: 1rem 1.05rem 1rem;
|
||||||
|
background: var(--paper);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
padding-bottom: 0.55rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, "Times New Roman", Times, serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp {
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp.filled {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 0.4rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row button {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row input[type="number"] {
|
||||||
|
width: 4.4rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row .of {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 0.7rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
margin: 0.15rem 0 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select,
|
||||||
|
.field textarea,
|
||||||
|
.field input {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
min-height: 3.4rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px dashed var(--rule);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot a {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0.75rem;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--rule);
|
||||||
|
background: color-mix(in srgb, var(--paper) 92%, transparent);
|
||||||
|
font-family: Georgia, "Times New Roman", Times, serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--ink);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-overlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted,
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-vault {
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-vault p {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-vault ol {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
width: min(34rem, calc(100vw - 2rem));
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet form {
|
||||||
|
padding: 1.1rem 1.15rem 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet h2 {
|
||||||
|
margin: 0 0 0.9rem;
|
||||||
|
font-family: Georgia, "Times New Roman", Times, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-fields .field.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
max-height: 11rem;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
data/library.json
Normal file
43
data/library.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": "books-msyr7amm",
|
||||||
|
"name": "Books",
|
||||||
|
"parentId": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "articles-msysxgse",
|
||||||
|
"name": "Articles",
|
||||||
|
"parentId": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"books": [
|
||||||
|
{
|
||||||
|
"id": "quick-start-kubernetes-msyslnvy",
|
||||||
|
"title": "Quick Start Kubernetes",
|
||||||
|
"author": "Nigel Poulton",
|
||||||
|
"format": "pdf",
|
||||||
|
"status": "reading",
|
||||||
|
"totalPages": 102,
|
||||||
|
"currentPage": 31,
|
||||||
|
"folderId": "books-msyr7amm",
|
||||||
|
"tags": [],
|
||||||
|
"notes": "",
|
||||||
|
"pdfPath": "D:\\Personal Projects\\ResourceLoader\\data\\pdfs\\quickstartkubernetes-2.pdf",
|
||||||
|
"sourceName": "quickstartkubernetes.pdf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "you-won-t-make-it-it-makes-you-msysxceg",
|
||||||
|
"title": "You Won’t Make It. It Makes You.",
|
||||||
|
"kind": "article",
|
||||||
|
"url": "https://medium.com/westenberg/you-wont-make-it-it-makes-you-5580fb0ae28c?sharedUserId=nworb",
|
||||||
|
"source": "",
|
||||||
|
"status": "backlog",
|
||||||
|
"folderId": "articles-msysxgse",
|
||||||
|
"tags": [],
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
292
electron/main.js
Normal file
292
electron/main.js
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
const { app, BrowserWindow, ipcMain, shell, dialog } = require("electron");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { importPdfFiles } = require("./pdf-import");
|
||||||
|
|
||||||
|
const EMPTY_CATALOG = {
|
||||||
|
folders: [],
|
||||||
|
books: [],
|
||||||
|
resources: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function settingsPath() {
|
||||||
|
return path.join(app.getPath("userData"), "settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettings() {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf8"));
|
||||||
|
return {
|
||||||
|
vaults: Array.isArray(data.vaults) ? data.vaults : [],
|
||||||
|
activeVaultId: data.activeVaultId || "",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { vaults: [], activeVaultId: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSettings(settings) {
|
||||||
|
const payload = {
|
||||||
|
vaults: Array.isArray(settings.vaults) ? settings.vaults : [],
|
||||||
|
activeVaultId: settings.activeVaultId || "",
|
||||||
|
};
|
||||||
|
fs.mkdirSync(path.dirname(settingsPath()), { recursive: true });
|
||||||
|
fs.writeFileSync(settingsPath(), JSON.stringify(payload, null, 2));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeVault(settings = readSettings()) {
|
||||||
|
return settings.vaults.find((vault) => vault.id === settings.activeVaultId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeVaultId(name) {
|
||||||
|
const slug = String(name || "vault")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 40);
|
||||||
|
return `${slug || "vault"}-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrSelectVault(folderPath, name) {
|
||||||
|
const settings = readSettings();
|
||||||
|
const resolved = path.resolve(folderPath);
|
||||||
|
let vault = settings.vaults.find((item) => path.resolve(item.path) === resolved);
|
||||||
|
if (!vault) {
|
||||||
|
vault = {
|
||||||
|
id: makeVaultId(name || path.basename(resolved)),
|
||||||
|
name: (name || path.basename(resolved)).trim() || path.basename(resolved),
|
||||||
|
path: resolved,
|
||||||
|
};
|
||||||
|
settings.vaults.push(vault);
|
||||||
|
}
|
||||||
|
settings.activeVaultId = vault.id;
|
||||||
|
return { settings: writeSettings(settings), vault };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVaultFiles(folderPath) {
|
||||||
|
if (!fs.existsSync(folderPath)) {
|
||||||
|
return { ok: false, reason: "missing-folder", path: folderPath };
|
||||||
|
}
|
||||||
|
const libraryFile = path.join(folderPath, "library.json");
|
||||||
|
fs.mkdirSync(path.join(folderPath, "pdfs"), { recursive: true });
|
||||||
|
if (!fs.existsSync(libraryFile)) {
|
||||||
|
fs.writeFileSync(libraryFile, JSON.stringify(EMPTY_CATALOG, null, 2));
|
||||||
|
}
|
||||||
|
return { ok: true, libraryPath: libraryFile, path: folderPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCatalog(raw) {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
folders: Array.isArray(data.folders) ? data.folders : [],
|
||||||
|
books: Array.isArray(data.books) ? data.books : [],
|
||||||
|
resources: Array.isArray(data.resources) ? data.resources : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLibrary() {
|
||||||
|
const settings = readSettings();
|
||||||
|
const vault = activeVault(settings);
|
||||||
|
if (!vault) {
|
||||||
|
return { ok: false, reason: "no-vault", settings };
|
||||||
|
}
|
||||||
|
const ensured = ensureVaultFiles(vault.path);
|
||||||
|
if (!ensured.ok) {
|
||||||
|
return { ...ensured, settings, vault };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
settings,
|
||||||
|
vault,
|
||||||
|
catalog: parseCatalog(fs.readFileSync(ensured.libraryPath, "utf8")),
|
||||||
|
libraryPath: ensured.libraryPath,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "unreadable",
|
||||||
|
error: err.message,
|
||||||
|
settings,
|
||||||
|
vault,
|
||||||
|
path: vault.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLibrary(data) {
|
||||||
|
const loaded = loadLibrary();
|
||||||
|
if (!loaded.ok) return loaded;
|
||||||
|
const payload = {
|
||||||
|
folders: Array.isArray(data.folders) ? data.folders : [],
|
||||||
|
books: Array.isArray(data.books) ? data.books : [],
|
||||||
|
resources: Array.isArray(data.resources) ? data.resources : [],
|
||||||
|
};
|
||||||
|
fs.writeFileSync(loaded.libraryPath, JSON.stringify(payload, null, 2));
|
||||||
|
return { ...loaded, catalog: payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePdfPath(vaultRoot, pdfPath) {
|
||||||
|
if (!pdfPath) return "";
|
||||||
|
if (path.isAbsolute(pdfPath) && fs.existsSync(pdfPath)) return pdfPath;
|
||||||
|
const joined = path.join(vaultRoot, pdfPath);
|
||||||
|
if (fs.existsSync(joined)) return joined;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPosixRelative(from, to) {
|
||||||
|
return path.relative(from, to).split(path.sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importIntoActiveVault(filePaths, existingNames) {
|
||||||
|
const loaded = loadLibrary();
|
||||||
|
if (!loaded.ok) {
|
||||||
|
return { imported: [], skipped: [], errors: [], cancelled: false, ...loaded };
|
||||||
|
}
|
||||||
|
const destDir = path.join(loaded.vault.path, "pdfs");
|
||||||
|
const result = await importPdfFiles(filePaths || [], destDir, existingNames || []);
|
||||||
|
result.imported = result.imported.map((item) => ({
|
||||||
|
...item,
|
||||||
|
pdfPath: toPosixRelative(loaded.vault.path, item.pdfPath),
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusedWindow() {
|
||||||
|
return BrowserWindow.getFocusedWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 900,
|
||||||
|
minHeight: 600,
|
||||||
|
backgroundColor: "#f3ead3",
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
win.loadFile(path.join(__dirname, "..", "index.html"));
|
||||||
|
win.maximize();
|
||||||
|
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
ipcMain.handle("library:load", () => loadLibrary());
|
||||||
|
ipcMain.handle("library:save", (_event, data) => saveLibrary(data));
|
||||||
|
ipcMain.handle("settings:load", () => {
|
||||||
|
const settings = readSettings();
|
||||||
|
return { settings, vault: activeVault(settings) };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("vault:open", async () => {
|
||||||
|
const result = await dialog.showOpenDialog(focusedWindow(), {
|
||||||
|
title: "Open vault folder",
|
||||||
|
properties: ["openDirectory"],
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.filePaths[0]) {
|
||||||
|
return { ok: false, cancelled: true, ...loadLibrary() };
|
||||||
|
}
|
||||||
|
const folderPath = result.filePaths[0];
|
||||||
|
if (!fs.existsSync(folderPath)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "missing-folder",
|
||||||
|
path: folderPath,
|
||||||
|
settings: readSettings(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
addOrSelectVault(folderPath, path.basename(folderPath));
|
||||||
|
return loadLibrary();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("vault:create", async (_event, name) => {
|
||||||
|
const folderName = String(name || "").trim();
|
||||||
|
if (!folderName) {
|
||||||
|
return { ok: false, reason: "no-name", settings: readSettings() };
|
||||||
|
}
|
||||||
|
const result = await dialog.showOpenDialog(focusedWindow(), {
|
||||||
|
title: "Choose parent folder for the new vault",
|
||||||
|
properties: ["openDirectory", "createDirectory"],
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.filePaths[0]) {
|
||||||
|
return { ok: false, cancelled: true, ...loadLibrary() };
|
||||||
|
}
|
||||||
|
const folderPath = path.join(result.filePaths[0], folderName);
|
||||||
|
fs.mkdirSync(folderPath, { recursive: true });
|
||||||
|
addOrSelectVault(folderPath, folderName);
|
||||||
|
return loadLibrary();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("vault:switch", (_event, id) => {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.vaults.some((vault) => vault.id === id)) {
|
||||||
|
return loadLibrary();
|
||||||
|
}
|
||||||
|
writeSettings({ ...settings, activeVaultId: id });
|
||||||
|
return loadLibrary();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("vault:remove", (_event, id) => {
|
||||||
|
const settings = readSettings();
|
||||||
|
const vaults = settings.vaults.filter((vault) => vault.id !== id);
|
||||||
|
const activeVaultId =
|
||||||
|
settings.activeVaultId === id ? vaults[0]?.id || "" : settings.activeVaultId;
|
||||||
|
writeSettings({ vaults, activeVaultId });
|
||||||
|
return loadLibrary();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("pdf:import", async (_event, existingNames) => {
|
||||||
|
const loaded = loadLibrary();
|
||||||
|
if (!loaded.ok) {
|
||||||
|
return { imported: [], skipped: [], errors: [], cancelled: false, ...loaded };
|
||||||
|
}
|
||||||
|
const result = await dialog.showOpenDialog(focusedWindow(), {
|
||||||
|
title: "Import PDFs",
|
||||||
|
properties: ["openFile", "multiSelections"],
|
||||||
|
filters: [{ name: "PDF documents", extensions: ["pdf"] }],
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.filePaths.length) {
|
||||||
|
return { imported: [], skipped: [], errors: [], cancelled: true };
|
||||||
|
}
|
||||||
|
return importIntoActiveVault(result.filePaths, existingNames);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("pdf:importPaths", async (_event, filePaths, existingNames) => {
|
||||||
|
return importIntoActiveVault(filePaths, existingNames);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("pdf:open", async (_event, pdfPath) => {
|
||||||
|
const vault = activeVault();
|
||||||
|
if (!vault) return { ok: false, error: "No vault open." };
|
||||||
|
const resolved = resolvePdfPath(vault.path, pdfPath);
|
||||||
|
if (!resolved) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "File not found. Wait for Google Drive to finish syncing, then try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const error = await shell.openPath(resolved);
|
||||||
|
return { ok: !error, error: error || "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") app.quit();
|
||||||
|
});
|
||||||
90
electron/pdf-import.js
Normal file
90
electron/pdf-import.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { PDFDocument } = require("pdf-lib");
|
||||||
|
|
||||||
|
function clean(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value).replace(/\0/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromFilename(filePath) {
|
||||||
|
return path
|
||||||
|
.basename(filePath, path.extname(filePath))
|
||||||
|
.replace(/[_]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeywords(value) {
|
||||||
|
if (!value) return [];
|
||||||
|
const list = Array.isArray(value) ? value : String(value).split(/[,;]+/);
|
||||||
|
return [...new Set(list.map(clean).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueDest(dir, originalName) {
|
||||||
|
const ext = path.extname(originalName) || ".pdf";
|
||||||
|
const stem =
|
||||||
|
path
|
||||||
|
.basename(originalName, ext)
|
||||||
|
.replace(/[<>:"/\\|?*]+/g, "-")
|
||||||
|
.slice(0, 80) || "document";
|
||||||
|
let dest = path.join(dir, `${stem}${ext}`);
|
||||||
|
let n = 2;
|
||||||
|
while (fs.existsSync(dest)) {
|
||||||
|
dest = path.join(dir, `${stem}-${n}${ext}`);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractMetadata(filePath) {
|
||||||
|
const bytes = fs.readFileSync(filePath);
|
||||||
|
const pdf = await PDFDocument.load(bytes, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
updateMetadata: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
title: clean(pdf.getTitle()) || titleFromFilename(filePath),
|
||||||
|
author: clean(pdf.getAuthor()),
|
||||||
|
totalPages: pdf.getPageCount(),
|
||||||
|
notes: clean(pdf.getSubject()),
|
||||||
|
tags: parseKeywords(pdf.getKeywords()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPdfFiles(filePaths, destDir, existingNames) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
const known = new Set((existingNames || []).map((name) => name.toLowerCase()));
|
||||||
|
const imported = [];
|
||||||
|
const skipped = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
const sourceName = path.basename(filePath);
|
||||||
|
if (!filePath.toLowerCase().endsWith(".pdf")) {
|
||||||
|
skipped.push({ file: sourceName, reason: "not a PDF" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (known.has(sourceName.toLowerCase())) {
|
||||||
|
skipped.push({ file: sourceName, reason: "already in catalog" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const meta = await extractMetadata(filePath);
|
||||||
|
const dest = uniqueDest(destDir, sourceName);
|
||||||
|
fs.copyFileSync(filePath, dest);
|
||||||
|
known.add(sourceName.toLowerCase());
|
||||||
|
imported.push({
|
||||||
|
...meta,
|
||||||
|
pdfPath: dest,
|
||||||
|
sourceName,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ file: sourceName, message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { importPdfFiles };
|
||||||
16
electron/preload.js
Normal file
16
electron/preload.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("libraryAPI", {
|
||||||
|
load: () => ipcRenderer.invoke("library:load"),
|
||||||
|
save: (data) => ipcRenderer.invoke("library:save", data),
|
||||||
|
settings: () => ipcRenderer.invoke("settings:load"),
|
||||||
|
openVault: () => ipcRenderer.invoke("vault:open"),
|
||||||
|
createVault: (name) => ipcRenderer.invoke("vault:create", name),
|
||||||
|
switchVault: (id) => ipcRenderer.invoke("vault:switch", id),
|
||||||
|
removeVault: (id) => ipcRenderer.invoke("vault:remove", id),
|
||||||
|
importPdfs: (existingNames) => ipcRenderer.invoke("pdf:import", existingNames),
|
||||||
|
importPdfPaths: (filePaths, existingNames) =>
|
||||||
|
ipcRenderer.invoke("pdf:importPaths", filePaths, existingNames),
|
||||||
|
openPdf: (pdfPath) => ipcRenderer.invoke("pdf:open", pdfPath),
|
||||||
|
pathForFile: (file) => webUtils.getPathForFile(file),
|
||||||
|
});
|
||||||
106
index.html
Normal file
106
index.html
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:" />
|
||||||
|
<title>Personal Library · Resource Index</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-head">
|
||||||
|
<p class="eyebrow">Vault</p>
|
||||||
|
<h2 id="vault-name">No vault</h2>
|
||||||
|
<label class="vault-picker">
|
||||||
|
<span>Switch vault</span>
|
||||||
|
<select id="vault-select"></select>
|
||||||
|
</label>
|
||||||
|
<div class="vault-actions">
|
||||||
|
<button type="button" id="open-vault">Open vault</button>
|
||||||
|
<button type="button" id="new-vault">New vault</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="remove-vault">Remove from this PC</button>
|
||||||
|
</div>
|
||||||
|
<p class="eyebrow folder-heading">Folders</p>
|
||||||
|
<nav class="folder-list" id="folder-list"></nav>
|
||||||
|
<div class="sidebar-actions">
|
||||||
|
<button type="button" id="new-folder">New folder</button>
|
||||||
|
</div>
|
||||||
|
<p class="file-path" id="file-path"></p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="masthead-row">
|
||||||
|
<div>
|
||||||
|
<h1>Personal Library</h1>
|
||||||
|
<p class="subtitle">Resource Index</p>
|
||||||
|
</div>
|
||||||
|
<div class="masthead-meta">
|
||||||
|
<p class="datestamp" id="datestamp"></p>
|
||||||
|
<button type="button" id="theme-toggle">Dark mode</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<label class="search">
|
||||||
|
<span>Search</span>
|
||||||
|
<input type="search" id="search" placeholder="title, author, source, tag" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="import-pdf">Import PDF</button>
|
||||||
|
<button type="button" id="add-book">Add book</button>
|
||||||
|
<button type="button" id="add-resource">Add resource</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="toast" id="toast" hidden></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="stats" id="stats" aria-label="Collection summary"></section>
|
||||||
|
|
||||||
|
<section class="filters" aria-label="Filters">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Format</legend>
|
||||||
|
<label><input type="checkbox" name="format" value="physical" /> Physical</label>
|
||||||
|
<label><input type="checkbox" name="format" value="pdf" /> PDF</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Kind</legend>
|
||||||
|
<label><input type="checkbox" name="kind" value="article" /> Article</label>
|
||||||
|
<label><input type="checkbox" name="kind" value="video" /> Video</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Status</legend>
|
||||||
|
<label><input type="checkbox" name="status" value="reading" /> Reading</label>
|
||||||
|
<label><input type="checkbox" name="status" value="in_progress" /> In progress</label>
|
||||||
|
<label><input type="checkbox" name="status" value="shelf" /> Shelf</label>
|
||||||
|
<label><input type="checkbox" name="status" value="paused" /> Paused</label>
|
||||||
|
<label><input type="checkbox" name="status" value="backlog" /> Backlog</label>
|
||||||
|
<label><input type="checkbox" name="status" value="finished" /> Finished</label>
|
||||||
|
<label><input type="checkbox" name="status" value="done" /> Done</label>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main id="catalog" class="catalog">
|
||||||
|
<p class="muted">Loading catalog…</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="drop-overlay" class="drop-overlay" hidden>Drop PDFs to import</div>
|
||||||
|
|
||||||
|
<dialog id="dialog" class="sheet">
|
||||||
|
<form id="dialog-form" method="dialog">
|
||||||
|
<h2 id="dialog-title">Add</h2>
|
||||||
|
<div id="dialog-fields" class="dialog-fields"></div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="submit" value="save">Save</button>
|
||||||
|
<button type="button" id="dialog-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
844
js/app.js
Normal file
844
js/app.js
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
const BOOK_STATUSES = ["reading", "shelf", "paused", "finished"];
|
||||||
|
const RESOURCE_STATUSES = ["backlog", "in_progress", "done"];
|
||||||
|
const UNFILED = "";
|
||||||
|
const THEME_KEY = "resourceLoader.theme";
|
||||||
|
|
||||||
|
const catalogEl = document.getElementById("catalog");
|
||||||
|
const statsEl = document.getElementById("stats");
|
||||||
|
const searchEl = document.getElementById("search");
|
||||||
|
const toastEl = document.getElementById("toast");
|
||||||
|
const dateEl = document.getElementById("datestamp");
|
||||||
|
const folderListEl = document.getElementById("folder-list");
|
||||||
|
const filePathEl = document.getElementById("file-path");
|
||||||
|
const vaultNameEl = document.getElementById("vault-name");
|
||||||
|
const vaultSelectEl = document.getElementById("vault-select");
|
||||||
|
const dialogEl = document.getElementById("dialog");
|
||||||
|
const dialogForm = document.getElementById("dialog-form");
|
||||||
|
const dialogTitle = document.getElementById("dialog-title");
|
||||||
|
const dialogFields = document.getElementById("dialog-fields");
|
||||||
|
|
||||||
|
let catalog = { folders: [], books: [], resources: [] };
|
||||||
|
let settings = { vaults: [], activeVaultId: "" };
|
||||||
|
let vault = null;
|
||||||
|
let vaultReady = false;
|
||||||
|
let activeFolder = "all";
|
||||||
|
let dialogMode = null;
|
||||||
|
|
||||||
|
dateEl.textContent = formatDate(new Date());
|
||||||
|
initTheme();
|
||||||
|
init();
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (!window.libraryAPI) {
|
||||||
|
catalogEl.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<p><strong>Desktop app required.</strong></p>
|
||||||
|
<p>Run <code>npm start</code> from this folder, or build an .exe with <code>npm run build</code>.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bind();
|
||||||
|
await applyLoadResult(await window.libraryAPI.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferredTheme() {
|
||||||
|
const saved = localStorage.getItem(THEME_KEY);
|
||||||
|
if (saved === "dark" || saved === "light") return saved;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
const button = document.getElementById("theme-toggle");
|
||||||
|
if (button) button.textContent = theme === "dark" ? "Light mode" : "Dark mode";
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
applyTheme(preferredTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||||
|
localStorage.setItem(THEME_KEY, next);
|
||||||
|
applyTheme(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
searchEl.addEventListener("input", render);
|
||||||
|
document.querySelectorAll(".filters input").forEach((input) => {
|
||||||
|
input.addEventListener("change", render);
|
||||||
|
});
|
||||||
|
document.getElementById("add-book").addEventListener("click", () => openBookDialog());
|
||||||
|
document.getElementById("add-resource").addEventListener("click", () => openResourceDialog());
|
||||||
|
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
|
||||||
|
document.getElementById("import-pdf").addEventListener("click", () => importPdfs());
|
||||||
|
document.getElementById("new-folder").addEventListener("click", () => openFolderDialog());
|
||||||
|
document.getElementById("open-vault").addEventListener("click", openExistingVault);
|
||||||
|
document.getElementById("new-vault").addEventListener("click", openNewVaultDialog);
|
||||||
|
document.getElementById("remove-vault").addEventListener("click", removeActiveVault);
|
||||||
|
vaultSelectEl.addEventListener("change", async (event) => {
|
||||||
|
await applyLoadResult(await window.libraryAPI.switchVault(event.target.value));
|
||||||
|
});
|
||||||
|
document.getElementById("dialog-cancel").addEventListener("click", () => dialogEl.close());
|
||||||
|
dialogForm.addEventListener("submit", onDialogSubmit);
|
||||||
|
|
||||||
|
folderListEl.addEventListener("click", onFolderClick);
|
||||||
|
catalogEl.addEventListener("click", onCatalogClick);
|
||||||
|
catalogEl.addEventListener("change", onCatalogChange);
|
||||||
|
catalogEl.addEventListener("focusout", onCatalogFocusOut);
|
||||||
|
bindDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function existingPdfNames() {
|
||||||
|
return catalog.books.map((book) => book.sourceName).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFolderId() {
|
||||||
|
return activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLoadResult(result) {
|
||||||
|
settings = result.settings || { vaults: [], activeVaultId: "" };
|
||||||
|
vault = result.vault || null;
|
||||||
|
vaultReady = Boolean(result.ok);
|
||||||
|
catalog = result.ok && result.catalog ? result.catalog : { folders: [], books: [], resources: [] };
|
||||||
|
activeFolder = "all";
|
||||||
|
renderVaultChrome();
|
||||||
|
setWorkspaceEnabled(vaultReady);
|
||||||
|
if (!result.ok) {
|
||||||
|
catalogEl.innerHTML = vaultEmptyHtml(result);
|
||||||
|
statsEl.innerHTML = "";
|
||||||
|
folderListEl.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVaultChrome() {
|
||||||
|
vaultNameEl.textContent = vault ? vault.name : "No vault";
|
||||||
|
filePathEl.textContent = vault ? vault.path : "Choose a Google Drive folder as a vault.";
|
||||||
|
vaultSelectEl.innerHTML = settings.vaults.length
|
||||||
|
? settings.vaults
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<option value="${escapeAttr(item.id)}" ${item.id === settings.activeVaultId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<option value="">No vaults on this PC</option>`;
|
||||||
|
vaultSelectEl.disabled = settings.vaults.length === 0;
|
||||||
|
document.getElementById("remove-vault").disabled = !vault;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWorkspaceEnabled(enabled) {
|
||||||
|
["import-pdf", "add-book", "add-resource", "new-folder", "search"].forEach((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.disabled = !enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultEmptyHtml(result) {
|
||||||
|
if (result.reason === "missing-folder") {
|
||||||
|
return `
|
||||||
|
<div class="error empty-vault">
|
||||||
|
<p><strong>Vault folder not found.</strong></p>
|
||||||
|
<p>${escapeHtml(result.path || "")}</p>
|
||||||
|
<p>If this lives on Google Drive, wait until Drive finishes syncing, then Open vault again. Don’t edit the same vault on two PCs at once.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (result.reason === "unreadable") {
|
||||||
|
return `
|
||||||
|
<div class="error empty-vault">
|
||||||
|
<p><strong>Could not read library.json.</strong></p>
|
||||||
|
<p>${escapeHtml(result.error || "")}</p>
|
||||||
|
<p>Drive may still be syncing. Try again in a moment.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="empty-vault">
|
||||||
|
<p><strong>No vault open on this computer.</strong></p>
|
||||||
|
<p>A vault is a folder (usually in Google Drive) with <code>library.json</code> and a <code>pdfs</code> folder. Use one vault for Work and another for Personal.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Install Google Drive for desktop and wait for folders to sync.</li>
|
||||||
|
<li>New vault: create Work or Personal under <code>ResourceLoader</code> in Drive.</li>
|
||||||
|
<li>Open vault: point this app at that folder. Work laptop: add Work only.</li>
|
||||||
|
</ol>
|
||||||
|
<p>Don’t edit the same vault on two PCs at once. Last save wins.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openExistingVault() {
|
||||||
|
const result = await window.libraryAPI.openVault();
|
||||||
|
if (result.cancelled) return;
|
||||||
|
await applyLoadResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewVaultDialog() {
|
||||||
|
dialogMode = "vault-create";
|
||||||
|
dialogTitle.textContent = "New vault";
|
||||||
|
dialogFields.innerHTML = `
|
||||||
|
<label class="field wide"><span>Name</span><input name="name" required placeholder="Work or Personal" /></label>
|
||||||
|
<p class="muted">Next you will choose the parent folder (for example your Google Drive ResourceLoader folder).</p>
|
||||||
|
`;
|
||||||
|
dialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeActiveVault() {
|
||||||
|
if (!vault) return;
|
||||||
|
if (!confirm(`Remove “${vault.name}” from this PC? Files in Drive are not deleted.`)) return;
|
||||||
|
await applyLoadResult(await window.libraryAPI.removeVault(vault.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPdfs() {
|
||||||
|
const result = await window.libraryAPI.importPdfs(existingPdfNames());
|
||||||
|
await applyImportResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImportResult(result) {
|
||||||
|
if (!result || result.cancelled) return;
|
||||||
|
if (result.ok === false) {
|
||||||
|
showToast(result.reason === "no-vault" ? "Open a vault first." : vaultMessage(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const folderId = currentFolderId();
|
||||||
|
for (const pdf of result.imported || []) {
|
||||||
|
catalog.books.push({
|
||||||
|
id: makeId(pdf.title),
|
||||||
|
title: pdf.title,
|
||||||
|
author: pdf.author || "",
|
||||||
|
format: "pdf",
|
||||||
|
status: "shelf",
|
||||||
|
totalPages: Number(pdf.totalPages) || 0,
|
||||||
|
currentPage: 0,
|
||||||
|
folderId,
|
||||||
|
tags: Array.isArray(pdf.tags) ? pdf.tags : [],
|
||||||
|
notes: pdf.notes || "",
|
||||||
|
pdfPath: pdf.pdfPath || "",
|
||||||
|
sourceName: pdf.sourceName || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.imported && result.imported.length) {
|
||||||
|
await persist();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
if (result.imported?.length) parts.push(`Imported ${result.imported.length} PDF${result.imported.length === 1 ? "" : "s"}.`);
|
||||||
|
if (result.skipped?.length) parts.push(`Skipped ${result.skipped.length}.`);
|
||||||
|
if (result.errors?.length) parts.push(`Failed ${result.errors.length}.`);
|
||||||
|
if (parts.length) showToast(parts.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDrop() {
|
||||||
|
const overlay = document.getElementById("drop-overlay");
|
||||||
|
let dragDepth = 0;
|
||||||
|
|
||||||
|
window.addEventListener("dragenter", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dragDepth += 1;
|
||||||
|
overlay.hidden = false;
|
||||||
|
});
|
||||||
|
window.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
overlay.hidden = false;
|
||||||
|
});
|
||||||
|
window.addEventListener("dragleave", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dragDepth = Math.max(0, dragDepth - 1);
|
||||||
|
if (dragDepth === 0) overlay.hidden = true;
|
||||||
|
});
|
||||||
|
window.addEventListener("drop", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dragDepth = 0;
|
||||||
|
overlay.hidden = true;
|
||||||
|
const files = [...(event.dataTransfer?.files || [])];
|
||||||
|
const paths = files
|
||||||
|
.map((file) => {
|
||||||
|
try {
|
||||||
|
return window.libraryAPI.pathForFile(file);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((filePath) => filePath && filePath.toLowerCase().endsWith(".pdf"));
|
||||||
|
if (!paths.length) {
|
||||||
|
showToast("Drop one or more PDF files.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await window.libraryAPI.importPdfPaths(paths, existingPdfNames());
|
||||||
|
await applyImportResult(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultMessage(result) {
|
||||||
|
if (result.reason === "missing-folder") return "Vault folder not found. Wait for Drive to sync.";
|
||||||
|
if (result.reason === "unreadable") return "Could not read library.json.";
|
||||||
|
if (result.reason === "no-vault") return "Open a vault first.";
|
||||||
|
return result.error || "Vault is not ready.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist() {
|
||||||
|
if (!vaultReady) return;
|
||||||
|
const result = await window.libraryAPI.save(catalog);
|
||||||
|
if (!result.ok) {
|
||||||
|
showToast(vaultMessage(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catalog = result.catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inFolder(item) {
|
||||||
|
if (activeFolder === "all") return true;
|
||||||
|
if (activeFolder === "unfiled") return !item.folderId;
|
||||||
|
return item.folderId === activeFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemCount(folderId) {
|
||||||
|
const match = (item) =>
|
||||||
|
folderId === "unfiled" ? !item.folderId : item.folderId === folderId;
|
||||||
|
return catalog.books.filter(match).length + catalog.resources.filter(match).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderOptions(selected) {
|
||||||
|
const rows = [`<option value="${UNFILED}" ${!selected ? "selected" : ""}>Unfiled</option>`];
|
||||||
|
for (const folder of sortedFolders()) {
|
||||||
|
const prefix = folder.parentId ? "— " : "";
|
||||||
|
rows.push(
|
||||||
|
`<option value="${escapeAttr(folder.id)}" ${folder.id === selected ? "selected" : ""}>${prefix}${escapeHtml(folder.name)}</option>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedFolders() {
|
||||||
|
const roots = catalog.folders.filter((f) => !f.parentId).sort(byName);
|
||||||
|
const children = catalog.folders.filter((f) => f.parentId);
|
||||||
|
const ordered = [];
|
||||||
|
for (const root of roots) {
|
||||||
|
ordered.push(root);
|
||||||
|
ordered.push(...children.filter((c) => c.parentId === root.id).sort(byName));
|
||||||
|
}
|
||||||
|
const hanging = children.filter((c) => !ordered.includes(c)).sort(byName);
|
||||||
|
return [...ordered, ...hanging];
|
||||||
|
}
|
||||||
|
|
||||||
|
function byName(a, b) {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilters() {
|
||||||
|
const checked = (name) =>
|
||||||
|
[...document.querySelectorAll(`input[name="${name}"]:checked`)].map((el) => el.value);
|
||||||
|
return {
|
||||||
|
query: searchEl.value.trim().toLowerCase(),
|
||||||
|
formats: checked("format"),
|
||||||
|
kinds: checked("kind"),
|
||||||
|
statuses: checked("status"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(item, filters) {
|
||||||
|
if (!inFolder(item)) return false;
|
||||||
|
if (filters.statuses.length && !filters.statuses.includes(item.status)) return false;
|
||||||
|
if (item.type === "book") {
|
||||||
|
if (filters.formats.length && !filters.formats.includes(item.format)) return false;
|
||||||
|
if (filters.kinds.length && !filters.formats.length) return false;
|
||||||
|
} else {
|
||||||
|
if (filters.kinds.length && !filters.kinds.includes(item.kind)) return false;
|
||||||
|
if (filters.formats.length && !filters.kinds.length) return false;
|
||||||
|
}
|
||||||
|
if (!filters.query) return true;
|
||||||
|
const hay = [
|
||||||
|
item.title,
|
||||||
|
item.author,
|
||||||
|
item.source,
|
||||||
|
item.format,
|
||||||
|
item.kind,
|
||||||
|
...(item.tags || []),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
return hay.includes(filters.query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagged(list, type) {
|
||||||
|
return list.map((item) => ({ ...item, type }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderFolders();
|
||||||
|
const books = tagged(catalog.books, "book");
|
||||||
|
const resources = tagged(catalog.resources, "resource");
|
||||||
|
const filters = getFilters();
|
||||||
|
const visibleBooks = books.filter((b) => matches(b, filters));
|
||||||
|
const visibleResources = resources.filter((r) => matches(r, filters));
|
||||||
|
|
||||||
|
renderStats(books.filter(inFolder), resources.filter(inFolder));
|
||||||
|
|
||||||
|
const nowReading = [
|
||||||
|
...visibleBooks.filter((b) => b.status === "reading"),
|
||||||
|
...visibleResources.filter((r) => r.status === "in_progress"),
|
||||||
|
];
|
||||||
|
const shelf = [
|
||||||
|
...visibleBooks.filter((b) => b.status === "shelf" || b.status === "paused"),
|
||||||
|
...visibleResources.filter((r) => r.status === "backlog"),
|
||||||
|
];
|
||||||
|
const finished = [
|
||||||
|
...visibleBooks.filter((b) => b.status === "finished"),
|
||||||
|
...visibleResources.filter((r) => r.status === "done"),
|
||||||
|
];
|
||||||
|
|
||||||
|
catalogEl.innerHTML = [
|
||||||
|
section("Now reading", nowReading),
|
||||||
|
section("On the shelf", shelf),
|
||||||
|
section("Finished", finished),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolders() {
|
||||||
|
const allCount = catalog.books.length + catalog.resources.length;
|
||||||
|
const items = [
|
||||||
|
folderButton("all", "All items", allCount, false),
|
||||||
|
folderButton("unfiled", "Unfiled", itemCount("unfiled"), false),
|
||||||
|
...sortedFolders().map((folder) =>
|
||||||
|
folderButton(folder.id, folder.name, itemCount(folder.id), Boolean(folder.parentId), folder.id)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
folderListEl.innerHTML = items.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderButton(id, name, count, nested, folderId) {
|
||||||
|
const active = activeFolder === id ? " active" : "";
|
||||||
|
const nest = nested ? " nested" : "";
|
||||||
|
const manage =
|
||||||
|
folderId
|
||||||
|
? `<button type="button" data-folder-action="rename" data-id="${escapeAttr(folderId)}" title="Rename">✎</button>
|
||||||
|
<button type="button" data-folder-action="delete" data-id="${escapeAttr(folderId)}" title="Delete">×</button>`
|
||||||
|
: "";
|
||||||
|
return `
|
||||||
|
<div class="folder-row">
|
||||||
|
<button type="button" class="folder-item${active}${nest}" data-folder="${escapeAttr(id)}">
|
||||||
|
<span>${escapeHtml(name)}</span>
|
||||||
|
<span class="count">${count}</span>
|
||||||
|
</button>
|
||||||
|
${manage}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats(books, resources) {
|
||||||
|
const reading = books.filter((b) => b.status === "reading").length;
|
||||||
|
const shelf = books.filter((b) => b.status === "shelf" || b.status === "paused").length;
|
||||||
|
const pages = books.reduce((sum, b) => sum + (Number(b.currentPage) || 0), 0);
|
||||||
|
const backlog = resources.filter((r) => r.status === "backlog").length;
|
||||||
|
statsEl.innerHTML = [
|
||||||
|
stat(reading, "Now reading"),
|
||||||
|
stat(shelf, "On the shelf"),
|
||||||
|
stat(pages, "Pages logged"),
|
||||||
|
stat(backlog, "Resource backlog"),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stat(value, label) {
|
||||||
|
return `<div class="stat"><span class="value">${escapeHtml(String(value))}</span><span class="label">${escapeHtml(label)}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title, items) {
|
||||||
|
const body =
|
||||||
|
items.length === 0
|
||||||
|
? `<p class="empty">— none —</p>`
|
||||||
|
: `<div class="cards">${items.map(card).join("")}</div>`;
|
||||||
|
return `<section class="section"><h2>${escapeHtml(title)}</h2>${body}</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(item) {
|
||||||
|
return item.type === "book" ? bookCard(item) : resourceCard(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookCard(book) {
|
||||||
|
const total = Number(book.totalPages) || 0;
|
||||||
|
const page = Number(book.currentPage) || 0;
|
||||||
|
const pct = total ? Math.round((page / total) * 100) : 0;
|
||||||
|
return `
|
||||||
|
<article class="card" data-id="${escapeAttr(book.id)}" data-type="book">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h3>${escapeHtml(book.title)}</h3>
|
||||||
|
<p class="meta">${escapeHtml(book.author || "Unknown author")}</p>
|
||||||
|
${tagsHtml(book.tags)}
|
||||||
|
</div>
|
||||||
|
<div class="stamps">
|
||||||
|
<span class="stamp">${escapeHtml(book.format || "book")}</span>
|
||||||
|
<span class="stamp filled">${escapeHtml(labelStatus(book.status))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-row">
|
||||||
|
<button type="button" data-action="page" data-delta="-1" aria-label="Previous page">−</button>
|
||||||
|
<input type="number" min="0" max="${total}" value="${page}" data-field="currentPage" aria-label="Current page" />
|
||||||
|
<span class="of">/ ${total}</span>
|
||||||
|
<button type="button" data-action="page" data-delta="1" aria-label="Next page">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="bar" aria-hidden="true"><span style="width:${pct}%"></span></div>
|
||||||
|
<div class="card-controls">
|
||||||
|
${statusField(book.status, BOOK_STATUSES)}
|
||||||
|
${folderField(book.folderId)}
|
||||||
|
${notesField(book.notes)}
|
||||||
|
</div>
|
||||||
|
<div class="card-foot">
|
||||||
|
<span>${pct}% of ${total} pp.</span>
|
||||||
|
<span class="card-foot-actions">
|
||||||
|
${book.pdfPath ? `<button type="button" data-action="open-pdf">Open PDF</button>` : ""}
|
||||||
|
<button type="button" data-action="delete">Delete</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceCard(item) {
|
||||||
|
const url = item.url || "";
|
||||||
|
return `
|
||||||
|
<article class="card" data-id="${escapeAttr(item.id)}" data-type="resource">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h3>${escapeHtml(item.title)}</h3>
|
||||||
|
<p class="meta">${escapeHtml(item.source || "Unknown source")}</p>
|
||||||
|
${tagsHtml(item.tags)}
|
||||||
|
</div>
|
||||||
|
<div class="stamps">
|
||||||
|
<span class="stamp">${escapeHtml(item.kind || "resource")}</span>
|
||||||
|
<span class="stamp filled">${escapeHtml(labelStatus(item.status))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-controls">
|
||||||
|
${statusField(item.status, RESOURCE_STATUSES)}
|
||||||
|
${folderField(item.folderId)}
|
||||||
|
${notesField(item.notes)}
|
||||||
|
</div>
|
||||||
|
<div class="card-foot">
|
||||||
|
${
|
||||||
|
url
|
||||||
|
? `<a href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(item.kind || "link")}</a>`
|
||||||
|
: `<span class="muted">No URL</span>`
|
||||||
|
}
|
||||||
|
<button type="button" data-action="delete">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusField(status, options) {
|
||||||
|
const opts = options
|
||||||
|
.map(
|
||||||
|
(value) =>
|
||||||
|
`<option value="${escapeAttr(value)}" ${value === status ? "selected" : ""}>${escapeHtml(labelStatus(value))}</option>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `<label class="field"><span>Status</span><select data-field="status">${opts}</select></label>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderField(folderId) {
|
||||||
|
return `<label class="field"><span>Folder</span><select data-field="folderId">${folderOptions(folderId || "")}</select></label>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notesField(notes) {
|
||||||
|
return `<label class="field wide"><span>Notes</span><textarea data-field="notes" rows="2">${escapeHtml(notes || "")}</textarea></label>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagsHtml(tags) {
|
||||||
|
if (!tags || !tags.length) return "";
|
||||||
|
return `<p class="tags">${tags.map((t) => `#${escapeHtml(t)}`).join(" ")}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelStatus(status) {
|
||||||
|
return String(status || "").replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFolderClick(event) {
|
||||||
|
const manage = event.target.closest("[data-folder-action]");
|
||||||
|
if (manage) {
|
||||||
|
const id = manage.dataset.id;
|
||||||
|
if (manage.dataset.folderAction === "rename") openFolderDialog(id);
|
||||||
|
if (manage.dataset.folderAction === "delete") deleteFolder(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = event.target.closest("[data-folder]");
|
||||||
|
if (!button) return;
|
||||||
|
activeFolder = button.dataset.folder;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolder(id) {
|
||||||
|
const folder = catalog.folders.find((f) => f.id === id);
|
||||||
|
if (!folder) return;
|
||||||
|
if (!confirm(`Delete folder “${folder.name}”? Items inside move to Unfiled.`)) return;
|
||||||
|
catalog.folders = catalog.folders.filter((f) => f.id !== id && f.parentId !== id);
|
||||||
|
for (const book of catalog.books) {
|
||||||
|
if (book.folderId === id) book.folderId = "";
|
||||||
|
}
|
||||||
|
for (const resource of catalog.resources) {
|
||||||
|
if (resource.folderId === id) resource.folderId = "";
|
||||||
|
}
|
||||||
|
if (activeFolder === id) activeFolder = "all";
|
||||||
|
await persist();
|
||||||
|
render();
|
||||||
|
showToast(`Deleted folder ${folder.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCatalogClick(event) {
|
||||||
|
const button = event.target.closest("button[data-action]");
|
||||||
|
if (!button) return;
|
||||||
|
const cardEl = button.closest(".card");
|
||||||
|
if (!cardEl) return;
|
||||||
|
const id = cardEl.dataset.id;
|
||||||
|
const kind = cardEl.dataset.type;
|
||||||
|
|
||||||
|
if (button.dataset.action === "delete") {
|
||||||
|
if (!confirm("Delete this item?")) return;
|
||||||
|
if (kind === "book") catalog.books = catalog.books.filter((b) => b.id !== id);
|
||||||
|
else catalog.resources = catalog.resources.filter((r) => r.id !== id);
|
||||||
|
await persist();
|
||||||
|
render();
|
||||||
|
showToast("Deleted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === "open-pdf") {
|
||||||
|
const book = catalog.books.find((b) => b.id === id);
|
||||||
|
if (!book?.pdfPath) return;
|
||||||
|
const opened = await window.libraryAPI.openPdf(book.pdfPath);
|
||||||
|
if (!opened.ok) showToast(opened.error || "Could not open PDF.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === "page") {
|
||||||
|
const book = catalog.books.find((b) => b.id === id);
|
||||||
|
if (!book) return;
|
||||||
|
const next = clamp(
|
||||||
|
(Number(book.currentPage) || 0) + Number(button.dataset.delta),
|
||||||
|
0,
|
||||||
|
Number(book.totalPages) || 0
|
||||||
|
);
|
||||||
|
book.currentPage = next;
|
||||||
|
await persist();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCatalogChange(event) {
|
||||||
|
const field = event.target.dataset.field;
|
||||||
|
const cardEl = event.target.closest(".card");
|
||||||
|
if (!field || !cardEl || field === "notes") return;
|
||||||
|
const item = findItem(cardEl.dataset.id, cardEl.dataset.type);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
if (field === "status") item.status = event.target.value;
|
||||||
|
if (field === "folderId") item.folderId = event.target.value;
|
||||||
|
if (field === "currentPage") {
|
||||||
|
const total = Number(item.totalPages) || 0;
|
||||||
|
item.currentPage = clamp(Number(event.target.value) || 0, 0, total);
|
||||||
|
}
|
||||||
|
await persist();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCatalogFocusOut(event) {
|
||||||
|
if (event.target.dataset.field !== "notes") return;
|
||||||
|
const cardEl = event.target.closest(".card");
|
||||||
|
if (!cardEl) return;
|
||||||
|
const item = findItem(cardEl.dataset.id, cardEl.dataset.type);
|
||||||
|
if (!item || item.notes === event.target.value) return;
|
||||||
|
item.notes = event.target.value;
|
||||||
|
await persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findItem(id, type) {
|
||||||
|
return type === "book"
|
||||||
|
? catalog.books.find((b) => b.id === id)
|
||||||
|
: catalog.resources.find((r) => r.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBookDialog() {
|
||||||
|
dialogMode = "book";
|
||||||
|
dialogTitle.textContent = "Add book";
|
||||||
|
const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
|
||||||
|
dialogFields.innerHTML = `
|
||||||
|
<label class="field wide"><span>Title</span><input name="title" required /></label>
|
||||||
|
<label class="field"><span>Author</span><input name="author" /></label>
|
||||||
|
<label class="field"><span>Format</span>
|
||||||
|
<select name="format">
|
||||||
|
<option value="physical">Physical</option>
|
||||||
|
<option value="pdf">PDF</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Status</span>
|
||||||
|
<select name="status">${options(BOOK_STATUSES, "shelf")}</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Total pages</span><input name="totalPages" type="number" min="0" value="0" /></label>
|
||||||
|
<label class="field"><span>Current page</span><input name="currentPage" type="number" min="0" value="0" /></label>
|
||||||
|
<label class="field"><span>Folder</span><select name="folderId">${folderOptions(folderPrefill)}</select></label>
|
||||||
|
<label class="field"><span>Tags</span><input name="tags" placeholder="comma separated" /></label>
|
||||||
|
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||||
|
`;
|
||||||
|
dialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResourceDialog() {
|
||||||
|
dialogMode = "resource";
|
||||||
|
dialogTitle.textContent = "Add resource";
|
||||||
|
const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
|
||||||
|
dialogFields.innerHTML = `
|
||||||
|
<label class="field wide"><span>Title</span><input name="title" required /></label>
|
||||||
|
<label class="field"><span>Kind</span>
|
||||||
|
<select name="kind">
|
||||||
|
<option value="article">Article</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Status</span>
|
||||||
|
<select name="status">${options(RESOURCE_STATUSES, "backlog")}</select>
|
||||||
|
</label>
|
||||||
|
<label class="field wide"><span>URL</span><input name="url" type="url" placeholder="https://" /></label>
|
||||||
|
<label class="field"><span>Source</span><input name="source" placeholder="site or channel" /></label>
|
||||||
|
<label class="field"><span>Folder</span><select name="folderId">${folderOptions(folderPrefill)}</select></label>
|
||||||
|
<label class="field wide"><span>Tags</span><input name="tags" placeholder="comma separated" /></label>
|
||||||
|
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
|
||||||
|
`;
|
||||||
|
dialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFolderDialog(id) {
|
||||||
|
const existing = catalog.folders.find((f) => f.id === id);
|
||||||
|
dialogMode = existing ? "folder-edit" : "folder";
|
||||||
|
dialogTitle.textContent = existing ? "Rename folder" : "New folder";
|
||||||
|
const parentPrefill = !existing && activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
|
||||||
|
dialogFields.innerHTML = `
|
||||||
|
<input type="hidden" name="id" value="${escapeAttr(existing ? existing.id : "")}" />
|
||||||
|
<label class="field wide"><span>Name</span><input name="name" required value="${escapeAttr(existing ? existing.name : "")}" /></label>
|
||||||
|
${
|
||||||
|
existing
|
||||||
|
? ""
|
||||||
|
: `<label class="field wide"><span>Inside</span>
|
||||||
|
<select name="parentId">
|
||||||
|
<option value="">Top level</option>
|
||||||
|
${catalog.folders
|
||||||
|
.filter((f) => !f.parentId)
|
||||||
|
.sort(byName)
|
||||||
|
.map(
|
||||||
|
(f) =>
|
||||||
|
`<option value="${escapeAttr(f.id)}" ${f.id === parentPrefill ? "selected" : ""}>${escapeHtml(f.name)}</option>`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</select>
|
||||||
|
</label>`
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
dialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(values, selected) {
|
||||||
|
return values
|
||||||
|
.map(
|
||||||
|
(value) =>
|
||||||
|
`<option value="${escapeAttr(value)}" ${value === selected ? "selected" : ""}>${escapeHtml(labelStatus(value))}</option>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDialogSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = Object.fromEntries(new FormData(dialogForm));
|
||||||
|
if (dialogMode === "book") {
|
||||||
|
catalog.books.push({
|
||||||
|
id: makeId(data.title),
|
||||||
|
title: data.title.trim(),
|
||||||
|
author: (data.author || "").trim(),
|
||||||
|
format: data.format === "pdf" ? "pdf" : "physical",
|
||||||
|
status: BOOK_STATUSES.includes(data.status) ? data.status : "shelf",
|
||||||
|
totalPages: Math.max(0, Number(data.totalPages) || 0),
|
||||||
|
currentPage: clamp(Number(data.currentPage) || 0, 0, Math.max(0, Number(data.totalPages) || 0)),
|
||||||
|
folderId: data.folderId || "",
|
||||||
|
tags: parseTags(data.tags),
|
||||||
|
notes: data.notes || "",
|
||||||
|
});
|
||||||
|
showToast("Book added.");
|
||||||
|
} else if (dialogMode === "resource") {
|
||||||
|
catalog.resources.push({
|
||||||
|
id: makeId(data.title),
|
||||||
|
title: data.title.trim(),
|
||||||
|
kind: data.kind === "video" ? "video" : "article",
|
||||||
|
url: (data.url || "").trim(),
|
||||||
|
source: (data.source || "").trim(),
|
||||||
|
status: RESOURCE_STATUSES.includes(data.status) ? data.status : "backlog",
|
||||||
|
folderId: data.folderId || "",
|
||||||
|
tags: parseTags(data.tags),
|
||||||
|
notes: data.notes || "",
|
||||||
|
});
|
||||||
|
showToast("Resource added.");
|
||||||
|
} else if (dialogMode === "folder") {
|
||||||
|
catalog.folders.push({
|
||||||
|
id: makeId(data.name),
|
||||||
|
name: data.name.trim(),
|
||||||
|
parentId: data.parentId || "",
|
||||||
|
});
|
||||||
|
showToast("Folder created.");
|
||||||
|
} else if (dialogMode === "folder-edit") {
|
||||||
|
const folder = catalog.folders.find((f) => f.id === data.id);
|
||||||
|
if (folder) folder.name = data.name.trim();
|
||||||
|
showToast("Folder renamed.");
|
||||||
|
} else if (dialogMode === "vault-create") {
|
||||||
|
dialogEl.close();
|
||||||
|
const result = await window.libraryAPI.createVault(data.name.trim());
|
||||||
|
if (result.cancelled) return;
|
||||||
|
await applyLoadResult(result);
|
||||||
|
showToast(result.ok ? "Vault created." : vaultMessage(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogEl.close();
|
||||||
|
await persist();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeId(title) {
|
||||||
|
const slug = String(title || "item")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 40);
|
||||||
|
return `${slug || "item"}-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message) {
|
||||||
|
toastEl.hidden = false;
|
||||||
|
toastEl.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return date
|
||||||
|
.toLocaleDateString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
5322
package-lock.json
generated
Normal file
5322
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "resource-loader",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Personal library and resource catalog",
|
||||||
|
"main": "electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"build": "electron-builder --win portable"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"pdf-lib": "^1.17.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^33.2.1",
|
||||||
|
"electron-builder": "^25.1.8"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "local.resourceloader.app",
|
||||||
|
"productName": "Resource Loader",
|
||||||
|
"files": [
|
||||||
|
"electron/**",
|
||||||
|
"index.html",
|
||||||
|
"css/**",
|
||||||
|
"js/**",
|
||||||
|
"data/**"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "portable",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user