Add drag-to-reorder, PDF/EPUB pattern viewing, and account security improvements
- Let projects and parts be reordered via touch/mouse-friendly drag handles - Support PDF and EPUB pattern files end-to-end (upload, page-flip viewer, EPUB content extraction) - Move password hashing to bcrypt with automatic migration from legacy SHA-256, and add email-based password reset - Externalize docker-compose credentials into .env instead of hardcoding them - Polish Pattern Composer UI (collapsible spec cards, tag input, file attach/view controls) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4a815cb764
commit
6f97d817ae
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,4 +8,5 @@ db_data/
|
||||
uploads/
|
||||
server/uploads/
|
||||
*.log
|
||||
.env
|
||||
server/.env
|
||||
|
||||
1124
assets/app.js
1124
assets/app.js
File diff suppressed because it is too large
Load Diff
1
assets/page-flip.browser.js
Normal file
1
assets/page-flip.browser.js
Normal file
File diff suppressed because one or more lines are too long
22
assets/pdfjs/pdf.min.js
vendored
Normal file
22
assets/pdfjs/pdf.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
assets/pdfjs/pdf.worker.min.js
vendored
Normal file
22
assets/pdfjs/pdf.worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
557
assets/style.css
557
assets/style.css
@ -150,6 +150,38 @@ h1 {
|
||||
|
||||
.fab-pattern { right: auto; left: 22px; background: var(--text); }
|
||||
.fab-pattern:hover { transform: scale(1.07); }
|
||||
.fab-ref { right: auto; left: 100px; background: var(--accent, #8a6b52); color: #fff; font-size: 1.3rem; width: 52px; height: 52px; bottom: 34px; }
|
||||
.fab-ref:hover { transform: scale(1.07); }
|
||||
|
||||
/* Continue-reading pill — sits above the FAB row */
|
||||
.reading-pill {
|
||||
position: fixed;
|
||||
bottom: 112px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
background: var(--accent, #8a6b52);
|
||||
color: #fff;
|
||||
border-radius: 30px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.25);
|
||||
cursor: pointer;
|
||||
z-index: 3;
|
||||
white-space: nowrap;
|
||||
max-width: calc(100vw - 40px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
border: none;
|
||||
}
|
||||
.reading-pill:hover { transform: translateX(-50%) translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.3); }
|
||||
|
||||
.btn-view-pattern { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1rem; padding: 4px 6px; border-radius: 6px; transition: color .15s; }
|
||||
.btn-view-pattern:hover { color: var(--text); }
|
||||
|
||||
/* Pattern Composer Overlay */
|
||||
.pattern-overlay {
|
||||
@ -189,7 +221,14 @@ h1 {
|
||||
}
|
||||
|
||||
.header-main { display: flex; align-items: center; gap: 16px; }
|
||||
.pattern-title-group { display: flex; flex-direction: column; gap: 1px; }
|
||||
.pattern-sheet-title { margin: 0; font-size: 1.25rem; color: var(--text); white-space: nowrap; }
|
||||
.pattern-header-subtitle {
|
||||
font-size: 0.8rem; color: var(--project-color); font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px;
|
||||
opacity: 0; transition: opacity 0.2s;
|
||||
}
|
||||
.pattern-header-subtitle.is-visible { opacity: 1; }
|
||||
|
||||
.pattern-modes { display: flex; background: var(--card-bg); padding: 3px; border-radius: 8px; border: 1px solid var(--border); }
|
||||
.pattern-mode {
|
||||
@ -208,6 +247,83 @@ h1 {
|
||||
}
|
||||
.icon-action:hover { background: var(--card-bg); color: var(--project-color); border-color: var(--border); }
|
||||
.icon-action.danger:hover { color: var(--danger); }
|
||||
.icon-action.primary-action {
|
||||
width: auto; padding: 0 10px; gap: 5px; font-size: 0.9rem; font-weight: 600;
|
||||
background: var(--text); color: var(--bg); border-color: var(--text);
|
||||
}
|
||||
.icon-action.primary-action:hover { background: var(--project-color); border-color: var(--project-color); color: white; }
|
||||
|
||||
/* Tab completeness dots */
|
||||
.tab-dot {
|
||||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: transparent; border: 1.5px solid var(--text-muted);
|
||||
vertical-align: middle; margin-left: 4px; transition: all 0.2s;
|
||||
}
|
||||
.tab-dot.is-filled { background: var(--project-color); border-color: var(--project-color); }
|
||||
|
||||
/* Collapsible spec cards */
|
||||
.collapsible-card .card-head-row {
|
||||
display: flex; align-items: center; cursor: pointer;
|
||||
gap: 8px; margin: -4px; padding: 4px; border-radius: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
.collapsible-card .card-head-row:hover { background: var(--bg); }
|
||||
.collapsible-card .card-head { margin: 0; flex: 1; }
|
||||
.collapsible-card .card-head-actions { margin-left: auto; }
|
||||
.collapsible-card .card-toggle-icon { color: var(--text-muted); font-size: 0.75rem; transition: transform 0.2s; flex-shrink: 0; }
|
||||
.collapsible-card.is-collapsed .card-toggle-icon { transform: rotate(-90deg); }
|
||||
.collapsible-card .card-content { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; }
|
||||
.collapsible-card.is-collapsed .card-content { display: none; }
|
||||
|
||||
/* Template picker blank state */
|
||||
.template-picker {
|
||||
padding: 24px 0;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 20px;
|
||||
}
|
||||
.template-picker-title { font-size: 1.1rem; font-weight: 700; color: var(--text); margin: 0; }
|
||||
.template-picker-sub { color: var(--text-muted); font-size: 0.9rem; margin: -12px 0 0; }
|
||||
.template-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px; width: 100%; max-width: 640px;
|
||||
}
|
||||
.template-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 16px 14px; cursor: pointer; text-align: center; transition: all 0.15s;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
}
|
||||
.template-card:hover { border-color: var(--project-color); background: var(--input-bg); transform: translateY(-1px); }
|
||||
.template-card .template-icon { font-size: 1.5rem; }
|
||||
.template-card .template-name { font-weight: 700; font-size: 0.95rem; }
|
||||
.template-card .template-desc { font-size: 0.8rem; color: var(--text-muted); }
|
||||
.template-card.import-card { border-style: dashed; }
|
||||
|
||||
/* Import pattern modal */
|
||||
.import-modal { min-width: 300px; max-width: 460px; text-align: center; padding: 32px 24px; }
|
||||
.import-state { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.import-state-title { font-size: 1.1rem; font-weight: 700; margin: 0; }
|
||||
.import-spinner-wrap { width: 56px; height: 56px; display: flex; align-items: center; justify-content: center; }
|
||||
.import-spinner {
|
||||
width: 40px; height: 40px; border: 3px solid var(--border);
|
||||
border-top-color: var(--project-color); border-radius: 50%;
|
||||
animation: importSpin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes importSpin { to { transform: rotate(360deg); } }
|
||||
.import-icon {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; font-weight: 700; color: white;
|
||||
}
|
||||
.import-icon.success-icon { background: var(--project-color); }
|
||||
.import-icon.error-icon { background: var(--danger); }
|
||||
.import-summary {
|
||||
background: var(--input-bg); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 14px 16px; width: 100%; text-align: left;
|
||||
}
|
||||
.import-summary-title { font-weight: 700; font-size: 1rem; margin-bottom: 6px; }
|
||||
.import-summary-by { font-weight: 400; color: var(--text-muted); font-size: 0.9rem; }
|
||||
.import-summary-meta { display: flex; gap: 10px; font-size: 0.85rem; color: var(--text-muted); flex-wrap: wrap; }
|
||||
.import-summary-meta span::before { content: "·"; margin-right: 10px; }
|
||||
.import-summary-meta span:first-child::before { content: none; margin: 0; }
|
||||
|
||||
.pattern-close {
|
||||
background: none; border: none; font-size: 1.8rem; color: var(--text-muted);
|
||||
@ -262,6 +378,8 @@ h1 {
|
||||
background: var(--input-bg); color: var(--text); font-family: inherit; font-size: 0.95rem;
|
||||
}
|
||||
.input-card input:focus, .input-card textarea:focus { outline: none; border-color: var(--project-color); box-shadow: 0 0 0 2px rgba(181, 107, 84, 0.1); }
|
||||
.field-error { border-color: var(--danger) !important; box-shadow: 0 0 0 2px rgba(181,107,84,0.2) !important; animation: shake 0.3s ease; }
|
||||
@keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
|
||||
|
||||
.field-group-inline { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
|
||||
|
||||
@ -363,21 +481,208 @@ h1 {
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
.pattern-library { display: grid; gap: 10px; }
|
||||
.pattern-library-item {
|
||||
/* Shelf header */
|
||||
.shelf-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--card-bg);
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pattern-lib-title { font-weight: 700; }
|
||||
.pattern-lib-subtitle { color: var(--text-muted); font-size: 0.9rem; }
|
||||
.pattern-lib-meta { color: var(--text-muted); font-size: 0.85rem; }
|
||||
.pattern-lib-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.shelf-header-text h4 { margin: 0 0 2px; }
|
||||
.shelf-header-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.shelf-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
}
|
||||
.shelf-btn:hover { background: var(--bg); }
|
||||
.shelf-btn--upload { border-color: var(--accent); color: var(--accent); }
|
||||
.shelf-btn--upload:hover { background: rgba(var(--accent-rgb, 120,80,40), .08); }
|
||||
|
||||
/* Shelf empty state */
|
||||
.shelf-empty { text-align: center; padding: 40px 20px; }
|
||||
.shelf-empty-icon { font-size: 2.8rem; color: var(--text-muted); opacity: .4; margin-bottom: 12px; }
|
||||
.shelf-empty-title { font-weight: 700; font-size: 1.1rem; margin: 0 0 6px; }
|
||||
|
||||
/* Pattern library cards */
|
||||
.pattern-library { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.pat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px 10px 10px;
|
||||
transition: box-shadow .15s, border-color .15s;
|
||||
}
|
||||
.pat-card:hover { box-shadow: 0 3px 14px rgba(0,0,0,.09); border-color: var(--accent); }
|
||||
|
||||
.pat-card-thumb {
|
||||
width: 52px;
|
||||
height: 66px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pat-card-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.pat-thumb--icon { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent); }
|
||||
|
||||
.pat-card-body { flex: 1; min-width: 0; }
|
||||
.pat-type-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.pat-type--composed { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
|
||||
.pat-type--pdf { background: rgba(220,50,50,.12); color: #c0392b; }
|
||||
.pat-type--epub { background: rgba(37,99,235,.12); color: #2563eb; }
|
||||
.pat-type--image, .pat-type--file { background: rgba(22,163,74,.12); color: #16a34a; }
|
||||
.pat-card-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.97rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pat-card-meta { color: var(--text-muted); font-size: 0.82rem; margin-top: 1px; }
|
||||
.pat-card-date { color: var(--text-muted); font-size: 0.76rem; margin-top: 2px; opacity: .7; }
|
||||
|
||||
/* Shelf filter bar */
|
||||
.shelf-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.shelf-filter-chip {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shelf-filter-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.shelf-filter-chip.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.shelf-tag-filters { display: contents; }
|
||||
|
||||
/* Tag pills in composer */
|
||||
.tag-input-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--input-bg);
|
||||
cursor: text;
|
||||
}
|
||||
.tag-input-wrap input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.tag-pill-list { display: contents; }
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px 2px 10px;
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-pill button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
opacity: .6;
|
||||
}
|
||||
.tag-pill button:hover { opacity: 1; }
|
||||
|
||||
/* Tag pills on pattern cards */
|
||||
.pat-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.pat-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pat-tag:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
|
||||
.pat-card-actions { display: flex; gap: 5px; flex-shrink: 0; }
|
||||
.pat-action-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.pat-action-btn:hover { background: var(--bg); color: var(--accent); border-color: var(--accent); }
|
||||
.pat-action-btn.danger:hover { background: rgba(220,50,50,.1); color: #c0392b; border-color: #c0392b; }
|
||||
|
||||
.pattern-step-card.is-collapsed .pattern-step-body { display: none; }
|
||||
.step-meta { color: var(--text-muted); font-size: 0.85rem; }
|
||||
@ -935,6 +1240,34 @@ h1 {
|
||||
}
|
||||
}
|
||||
|
||||
/* --- DRAG TO REORDER --- */
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 6px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drag-handle:hover { color: var(--project-color); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.project-container.dragging,
|
||||
.part-card.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.drag-ghost {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.35);
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* --- PROJECT CONTAINER --- */
|
||||
.project-container {
|
||||
background: var(--card-bg);
|
||||
@ -1269,7 +1602,10 @@ button:active { transform: scale(0.97); box-shadow: none; }
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.empty-state { text-align: center; color: var(--text-muted); margin-top: 80px; font-size: 1.2rem; font-style: italic;}
|
||||
.empty-state { text-align: center; color: var(--text-muted); margin-top: 80px; font-size: 1.2rem; font-style: italic; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
|
||||
.empty-state p { margin: 0; }
|
||||
.empty-state-sub { font-size: 0.95rem; }
|
||||
.import-from-empty { margin-top: 0.75rem; font-style: normal; font-size: 1rem; }
|
||||
|
||||
.footer-bg {
|
||||
position: fixed;
|
||||
@ -1705,7 +2041,7 @@ button:active { transform: scale(0.97); box-shadow: none; }
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 300;
|
||||
z-index: 400;
|
||||
padding: 16px;
|
||||
}
|
||||
.image-preview-overlay.active { display: flex; }
|
||||
@ -1782,7 +2118,12 @@ button:active { transform: scale(0.97); box-shadow: none; }
|
||||
|
||||
/* Abbreviation Modal Polish */
|
||||
#abbrevModal {
|
||||
z-index: 230; /* Ensure it pops over Pattern Composer (z-index 215) */
|
||||
z-index: 230; /* above pattern composer (215) */
|
||||
}
|
||||
|
||||
/* File viewer always floats above composer, auth, abbrev — but below alerts */
|
||||
#patternFileViewerOverlay {
|
||||
z-index: 260;
|
||||
}
|
||||
#abbrevModal .modal-content {
|
||||
padding: 0; /* Remove default padding for full-bleed feel */
|
||||
@ -1990,7 +2331,7 @@ button:active { transform: scale(0.97); box-shadow: none; }
|
||||
}
|
||||
|
||||
|
||||
/* Sweet-ish alerts */
|
||||
/* Sweet-ish alerts — always on top of everything */
|
||||
.swal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@ -1998,7 +2339,7 @@ button:active { transform: scale(0.97); box-shadow: none; }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 300;
|
||||
z-index: 500;
|
||||
padding: 20px;
|
||||
animation: overlayFade 0.22s ease;
|
||||
}
|
||||
@ -2048,6 +2389,188 @@ button:active { transform: scale(0.97); box-shadow: none; }
|
||||
.swal-confirm { background: var(--project-color); color: var(--card-bg); }
|
||||
.swal-cancel { background: var(--lock-btn-bg); color: var(--text); }
|
||||
.swal-danger { background: var(--danger); color: var(--card-bg); }
|
||||
/* ── Pattern File Card ── */
|
||||
.pattern-file-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.pattern-file-icon { font-size: 1.6rem; flex-shrink: 0; }
|
||||
.pattern-file-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.pattern-file-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pattern-file-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
/* ── Pattern File Viewer ── */
|
||||
.viewer-shell {
|
||||
display: flex; flex-direction: column;
|
||||
width: 100%; height: 100%;
|
||||
background: #1a1612;
|
||||
color: #f0ece0;
|
||||
}
|
||||
.viewer-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 16px; background: #111; flex-shrink: 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.viewer-title { font-family: 'Playfair Display', Georgia, serif; font-size: 1rem; font-weight: 600; opacity: .85; }
|
||||
.viewer-close {
|
||||
background: none; border: none; color: #ccc; font-size: 1.6rem; cursor: pointer;
|
||||
line-height: 1; padding: 0 4px;
|
||||
}
|
||||
.viewer-close:hover { color: #fff; }
|
||||
|
||||
.viewer-back-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: none; border: 1px solid #444; color: #bbb;
|
||||
font-size: .8rem; padding: 5px 10px; border-radius: 20px; cursor: pointer;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viewer-back-btn:hover { background: #333; color: #fff; border-color: #888; }
|
||||
|
||||
.viewer-stage {
|
||||
flex: 1; position: relative; overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #1c1814;
|
||||
}
|
||||
|
||||
/* StPageFlip book container */
|
||||
.viewer-book {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Individual page divs fed to StPageFlip */
|
||||
.viewer-book-page {
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.viewer-book-page canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.viewer-epub-page {
|
||||
padding: 2em 2.2em;
|
||||
font-family: Georgia, serif;
|
||||
line-height: 1.8;
|
||||
color: #1a1a1a;
|
||||
font-size: 15px;
|
||||
overflow-y: auto;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.viewer-epub-page img { max-width: 100%; height: auto; }
|
||||
.viewer-epub-page h1, .viewer-epub-page h2, .viewer-epub-page h3 { margin-top: 1em; }
|
||||
.viewer-epub-page p { margin: .6em 0; }
|
||||
|
||||
.viewer-loading {
|
||||
position: absolute; inset: 0; display: flex;
|
||||
flex-direction: column; align-items: center; justify-content: center; gap: 12px;
|
||||
color: #aaa; font-style: italic; z-index: 10;
|
||||
}
|
||||
|
||||
.viewer-controls {
|
||||
display: flex; align-items: center; justify-content: center; gap: 24px;
|
||||
padding: 12px 16px; background: #111; flex-shrink: 0;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
.viewer-nav-btn {
|
||||
background: none; border: 1px solid #555; color: #ddd;
|
||||
font-size: 2rem; line-height: 1; width: 44px; height: 44px;
|
||||
border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.viewer-nav-btn:hover:not(:disabled) { background: #333; border-color: #888; }
|
||||
.viewer-nav-btn:disabled { opacity: .3; cursor: default; }
|
||||
.viewer-page-info { font-size: .9rem; color: #aaa; min-width: 90px; text-align: center; }
|
||||
.viewer-page-info:hover { color: #fff; text-decoration: underline dotted; }
|
||||
|
||||
.viewer-zoom-controls {
|
||||
display: flex; align-items: center; gap: 4px; margin-left: 8px;
|
||||
border-left: 1px solid #333; padding-left: 12px;
|
||||
}
|
||||
.viewer-zoom-btn {
|
||||
background: none; border: 1px solid #444; color: #ccc;
|
||||
font-size: 1rem; width: 30px; height: 30px; border-radius: 6px;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: background .12s;
|
||||
}
|
||||
.viewer-zoom-btn:hover { background: #2a2a2a; color: #fff; }
|
||||
.viewer-zoom-reset { font-size: .75rem; }
|
||||
.viewer-zoom-display { font-size: .75rem; color: #888; min-width: 38px; text-align: center; }
|
||||
|
||||
/* StPageFlip overrides */
|
||||
.stf__parent { width: 100% !important; height: 100% !important; }
|
||||
/* Allow EPUB pages to scroll — StPageFlip adds .stf__item to our divs */
|
||||
.stf__item.viewer-epub-page,
|
||||
.viewer-epub-page { overflow-y: auto !important; }
|
||||
|
||||
/* Viewer counter bar — lets you count without leaving the pattern */
|
||||
.viewer-counter-bar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px 14px;
|
||||
background: #0e0e0e;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.viewer-counter-info {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
flex: 1; min-width: 0; overflow: hidden;
|
||||
}
|
||||
.viewer-counter-project {
|
||||
font-size: .75rem; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px;
|
||||
}
|
||||
.viewer-counter-sep { font-size: .6rem; color: #555; flex-shrink: 0; }
|
||||
.viewer-counter-part {
|
||||
font-size: .8rem; color: #ccc; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.viewer-counter-btns {
|
||||
display: flex; align-items: center; gap: 10px; flex-shrink: 0;
|
||||
}
|
||||
.viewer-count-btn {
|
||||
background: #1e1e1e; border: 1px solid #444; color: #eee;
|
||||
font-size: 1.3rem; line-height: 1; width: 38px; height: 38px;
|
||||
border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: background .12s, border-color .12s;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
.viewer-count-btn:hover:not(:disabled) { background: #333; border-color: #777; }
|
||||
.viewer-count-btn:active:not(:disabled) { background: #444; transform: scale(.93); }
|
||||
.viewer-count-btn:disabled { opacity: .3; cursor: default; }
|
||||
.viewer-count-display {
|
||||
font-size: 1.2rem; font-weight: 700; color: #fff;
|
||||
min-width: 56px; text-align: center; letter-spacing: .02em;
|
||||
transition: transform .1s;
|
||||
}
|
||||
@keyframes viewerCountBump { 0%,100% { transform: scale(1); } 40% { transform: scale(1.28); } }
|
||||
.viewer-count-bump { animation: viewerCountBump .22s ease-out; }
|
||||
.viewer-goto-counter {
|
||||
background: none; border: 1px solid #3a3a3a; color: #888;
|
||||
width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; font-size: .85rem;
|
||||
flex-shrink: 0; transition: background .12s, color .12s;
|
||||
}
|
||||
.viewer-goto-counter:hover { background: #222; color: #ccc; }
|
||||
|
||||
/* Active part highlight — shows which part the viewer bar is tracking */
|
||||
.part-card.is-active-part {
|
||||
outline: 2px solid var(--project-color, #8a6b52);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.part-card.is-active-part .part-name::after {
|
||||
content: ' ●';
|
||||
font-size: .5em;
|
||||
vertical-align: middle;
|
||||
opacity: .7;
|
||||
color: var(--project-color, #8a6b52);
|
||||
}
|
||||
|
||||
@keyframes overlayFade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes dialogPop {
|
||||
0% { transform: translateY(8px) scale(0.96); opacity: 0; }
|
||||
|
||||
@ -2,10 +2,11 @@ services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_USER: toadstool
|
||||
POSTGRES_PASSWORD: toadstool
|
||||
POSTGRES_DB: toadstool
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
@ -14,12 +15,7 @@ services:
|
||||
api:
|
||||
build: .
|
||||
# restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgres://toadstool:toadstool@db:5432/toadstool
|
||||
PORT: 4000
|
||||
UPLOAD_DIR: /app/server/uploads
|
||||
ADMIN_EMAIL: chris@chrisedwards.tech
|
||||
ADMIN_PASSWORD: R4e3w2q1
|
||||
env_file: .env
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/server/node_modules
|
||||
|
||||
229
index.html
229
index.html
@ -36,7 +36,12 @@
|
||||
<div class="container" id="app"></div>
|
||||
|
||||
<button class="fab" onclick="openModal('addProject')">+</button>
|
||||
<button class="fab fab-ref" id="refFab" onclick="openReferenceViewer()" title="Open pattern (Esc to return to counter)" style="display:none"><i class="fa-solid fa-book-open"></i></button>
|
||||
<button class="fab fab-pattern" id="patternFab" onclick="openPatternComposer()" title="Open Pattern Composer"><i class="fa-solid fa-swatchbook"></i></button>
|
||||
<div class="reading-pill" id="readingPill" style="display:none" onclick="openLastReadPattern()">
|
||||
<i class="fa-solid fa-book-open-reader"></i>
|
||||
<span id="readingPillLabel">Continue reading</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="modalOverlay">
|
||||
<div class="modal-content">
|
||||
@ -232,7 +237,10 @@
|
||||
<div class="pattern-sheet">
|
||||
<div class="pattern-sheet-header">
|
||||
<div class="header-main">
|
||||
<h2 class="pattern-sheet-title">Pattern Composer</h2>
|
||||
<div class="pattern-title-group">
|
||||
<h2 class="pattern-sheet-title">Pattern Composer</h2>
|
||||
<span id="patternHeaderTitle" class="pattern-header-subtitle"></span>
|
||||
</div>
|
||||
<div class="pattern-modes">
|
||||
<button class="pattern-mode" data-mode="crochet" onclick="setPatternMode('crochet')">Crochet</button>
|
||||
<button class="pattern-mode" data-mode="knit" onclick="setPatternMode('knit')">Knit</button>
|
||||
@ -240,88 +248,116 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span class="pattern-save-indicator" id="patternSaveIndicator">Saved</span>
|
||||
<button class="icon-action" onclick="savePatternDraft()" title="Save to Basket"><i class="fa-solid fa-bookmark"></i></button>
|
||||
<button class="icon-action" onclick="savePatternAndStartProject()" title="Save & Start Project"><i class="fa-solid fa-play"></i></button>
|
||||
<button class="icon-action primary-action" onclick="savePatternDraft()" title="Save to Shelf"><i class="fa-solid fa-bookmark"></i> Save</button>
|
||||
<button class="icon-action" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i></button>
|
||||
<button class="icon-action" onclick="sharePattern()" title="Share Link"><i class="fa-solid fa-link"></i></button>
|
||||
<button class="icon-action danger" onclick="clearPatternOutput()" title="Clear Draft"><i class="fa-solid fa-eraser"></i></button>
|
||||
<button class="icon-action" id="composerViewFileBtn" onclick="openPatternFileViewer()" title="View attached file" style="display:none"><i class="fa-solid fa-book-open"></i></button>
|
||||
<button class="icon-action" id="composerAttachBtn" onclick="composerAttachFile()" title="Attach source file"><i class="fa-solid fa-paperclip"></i></button>
|
||||
<button class="pattern-close" onclick="closePatternComposer()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-nav">
|
||||
<button class="nav-item active" data-tab="info" onclick="showPatternTab('info')">Specs</button>
|
||||
<button class="nav-item" data-tab="steps" onclick="showPatternTab('steps')">Draft</button>
|
||||
<button class="nav-item" data-tab="view" onclick="showPatternTab('view')">Read</button>
|
||||
<button class="nav-item" data-tab="library" onclick="showPatternTab('library')">Shelf</button>
|
||||
<button class="nav-item active" data-tab="info" onclick="showPatternTab('info')">Specs <span class="tab-dot" id="tabDot-info"></span></button>
|
||||
<button class="nav-item" data-tab="steps" onclick="showPatternTab('steps')">Draft <span class="tab-dot" id="tabDot-steps"></span></button>
|
||||
<button class="nav-item" data-tab="view" onclick="showPatternTab('view')">Read <span class="tab-dot" id="tabDot-view"></span></button>
|
||||
<button class="nav-item" data-tab="library" onclick="showPatternTab('library')">Shelf <span class="tab-dot" id="tabDot-library"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="pattern-body">
|
||||
<div class="pattern-section active" data-section="info">
|
||||
<div class="card-grid">
|
||||
<div class="input-card">
|
||||
<h4 class="card-head">Basic Details</h4>
|
||||
<div class="form-row">
|
||||
<label class="field-label" for="patternTitle">Title</label>
|
||||
<input id="patternTitle" type="text" placeholder="e.g., Baby Fox Plush">
|
||||
<div class="input-card collapsible-card" id="specCard-basic">
|
||||
<div class="card-head-row" onclick="toggleSpecsCard('specCard-basic')">
|
||||
<h4 class="card-head">Basic Details</h4>
|
||||
<i class="fa-solid fa-chevron-down card-toggle-icon"></i>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="field-label" for="patternDesigner">Designer</label>
|
||||
<input id="patternDesigner" type="text" placeholder="Your name or shop">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-card">
|
||||
<h4 class="card-head">Yarn & Tools</h4>
|
||||
<label class="field-label">Yarns</label>
|
||||
<div id="yarnList" class="yarn-list-container">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
|
||||
<label class="field-label" style="margin-top: 10px;">Hooks / Needles</label>
|
||||
<div id="hookList" class="hook-list-container">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
|
||||
<div class="field-group-inline" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label class="field-label" for="patternGaugeSts">Sts / 4in</label>
|
||||
<input id="patternGaugeSts" type="text" placeholder="e.g., 16 sc">
|
||||
<div class="card-content">
|
||||
<div class="form-row">
|
||||
<label class="field-label" for="patternTitle">Title</label>
|
||||
<input id="patternTitle" type="text" placeholder="e.g., Baby Fox Plush">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="patternGaugeRows">Rows / 4in</label>
|
||||
<input id="patternGaugeRows" type="text" placeholder="e.g., 18 rows">
|
||||
<div class="form-row">
|
||||
<label class="field-label" for="patternDesigner">Designer</label>
|
||||
<input id="patternDesigner" type="text" placeholder="Your name or shop">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="field-label" for="patternTagInput">Tags</label>
|
||||
<div class="tag-input-wrap">
|
||||
<div id="patternTagList" class="tag-pill-list"></div>
|
||||
<input id="patternTagInput" type="text" placeholder="Add tag, press Enter" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field-label" for="patternGauge">Gauge Notes</label>
|
||||
<textarea id="patternGauge" rows="2" placeholder="Magic ring start; or any extra gauge notes"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-card full-width">
|
||||
<h4 class="card-head">Materials & Notes</h4>
|
||||
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
|
||||
<textarea id="patternMaterials" rows="3" placeholder="• 1 skein Worsted Weight Yarn (Main Color) • 4.0mm Hook • Tapestry Needle • Polyester Fiberfill"></textarea>
|
||||
|
||||
<label class="field-label" for="patternNotes">Notes / Finishing</label>
|
||||
<textarea id="patternNotes" rows="2" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
||||
<div class="input-card collapsible-card" id="specCard-yarns">
|
||||
<div class="card-head-row" onclick="toggleSpecsCard('specCard-yarns')">
|
||||
<h4 class="card-head">Yarn & Tools</h4>
|
||||
<i class="fa-solid fa-chevron-down card-toggle-icon"></i>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<label class="field-label">Yarns</label>
|
||||
<div id="yarnList" class="yarn-list-container">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
|
||||
<label class="field-label" style="margin-top: 10px;">Finished Photos</label>
|
||||
<div id="finishedPhotoList" class="finished-photo-list"></div>
|
||||
<button class="secondary small" type="button" onclick="addFinishedPhoto()">Add Photo</button>
|
||||
<label class="field-label" style="margin-top: 10px;">Hooks / Needles</label>
|
||||
<div id="hookList" class="hook-list-container">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
|
||||
<div class="field-group-inline" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label class="field-label" for="patternGaugeSts">Sts / 4in</label>
|
||||
<input id="patternGaugeSts" type="text" placeholder="e.g., 16 sc">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="patternGaugeRows">Rows / 4in</label>
|
||||
<input id="patternGaugeRows" type="text" placeholder="e.g., 18 rows">
|
||||
</div>
|
||||
</div>
|
||||
<label class="field-label" for="patternGauge">Gauge Notes</label>
|
||||
<textarea id="patternGauge" rows="2" placeholder="Magic ring start; or any extra gauge notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-card full-width">
|
||||
<div class="abbrev-head">
|
||||
<h4 class="card-head" style="margin:0;">Abbreviations</h4>
|
||||
<button class="secondary small" onclick="openAbbrevModal()">Edit / Add</button>
|
||||
<div class="input-card collapsible-card full-width" id="specCard-materials">
|
||||
<div class="card-head-row" onclick="toggleSpecsCard('specCard-materials')">
|
||||
<h4 class="card-head">Materials & Notes</h4>
|
||||
<i class="fa-solid fa-chevron-down card-toggle-icon"></i>
|
||||
</div>
|
||||
<div id="abbrevSummary" class="selected-abbrev">
|
||||
<!-- Selected pills will appear here -->
|
||||
<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>
|
||||
<div class="card-content">
|
||||
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
|
||||
<textarea id="patternMaterials" rows="3" placeholder="• 1 skein Worsted Weight Yarn (Main Color) • 4.0mm Hook • Tapestry Needle • Polyester Fiberfill"></textarea>
|
||||
|
||||
<label class="field-label" for="patternNotes">Notes / Finishing</label>
|
||||
<textarea id="patternNotes" rows="2" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
||||
|
||||
<label class="field-label" style="margin-top: 10px;">Finished Photos</label>
|
||||
<div id="finishedPhotoList" class="finished-photo-list"></div>
|
||||
<button class="secondary small" type="button" onclick="addFinishedPhoto()">Add Photo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-card collapsible-card full-width" id="specCard-abbrev">
|
||||
<div class="card-head-row" onclick="toggleSpecsCard('specCard-abbrev')">
|
||||
<h4 class="card-head">Abbreviations</h4>
|
||||
<div class="card-head-actions" onclick="event.stopPropagation()">
|
||||
<button class="secondary small" onclick="openAbbrevModal()">Edit / Add</button>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-down card-toggle-icon"></i>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div id="abbrevSummary" class="selected-abbrev">
|
||||
<!-- Selected pills will appear here -->
|
||||
<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>
|
||||
</div>
|
||||
<textarea id="patternAbbrev" rows="3" placeholder="Selected abbreviations will appear here..." readonly></textarea>
|
||||
<label class="field-label" for="patternStitches">Special Stitches / Notes</label>
|
||||
<textarea id="patternStitches" rows="2" placeholder="Magic ring: ... Invisible decrease: ..."></textarea>
|
||||
</div>
|
||||
<textarea id="patternAbbrev" rows="3" placeholder="Selected abbreviations will appear here..." readonly></textarea>
|
||||
<label class="field-label" for="patternStitches">Special Stitches / Notes</label>
|
||||
<textarea id="patternStitches" rows="2" placeholder="Magic ring: ... Invisible decrease: ..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pattern-footer-actions">
|
||||
@ -340,20 +376,35 @@
|
||||
</div>
|
||||
|
||||
<div class="pattern-section" data-section="library">
|
||||
<div class="pattern-steps-head">
|
||||
<div>
|
||||
<h4>Saved Patterns</h4>
|
||||
<p class="muted small">Search, load, or start a project.</p>
|
||||
<div class="shelf-header">
|
||||
<div class="shelf-header-text">
|
||||
<h4>My Pattern Shelf</h4>
|
||||
<p class="muted small">Your composed and imported patterns, all in one place.</p>
|
||||
</div>
|
||||
<div class="shelf-header-actions">
|
||||
<button class="shelf-btn shelf-btn--upload" onclick="uploadToLibrary()" title="Upload a PDF, EPUB, or image">
|
||||
<i class="fa-solid fa-upload"></i> Upload
|
||||
</button>
|
||||
<button class="shelf-btn shelf-btn--save" onclick="savePatternDraft()" title="Save current composer draft">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Save Draft
|
||||
</button>
|
||||
</div>
|
||||
<button class="primary" onclick="savePatternDraft()">Save Current Draft</button>
|
||||
</div>
|
||||
<div class="pattern-library-controls">
|
||||
<input id="patternSearch" type="text" placeholder="Search patterns..." oninput="renderPatternLibrary()">
|
||||
<input id="patternSearch" type="text" placeholder="Search patterns, tags..." oninput="renderPatternLibrary()">
|
||||
<select id="patternSort" onchange="renderPatternLibrary()">
|
||||
<option value="recent">Most recent</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="shelf-filter-bar" id="shelfFilterBar">
|
||||
<button class="shelf-filter-chip active" data-filter="all" onclick="setShelfFilter('all')">All</button>
|
||||
<button class="shelf-filter-chip" data-filter="crochet" onclick="setShelfFilter('crochet')">Crochet</button>
|
||||
<button class="shelf-filter-chip" data-filter="knit" onclick="setShelfFilter('knit')">Knitting</button>
|
||||
<button class="shelf-filter-chip" data-filter="pdf" onclick="setShelfFilter('pdf')">PDF</button>
|
||||
<button class="shelf-filter-chip" data-filter="epub" onclick="setShelfFilter('epub')">EPUB</button>
|
||||
<div id="shelfTagFilters" class="shelf-tag-filters"></div>
|
||||
</div>
|
||||
<div id="patternLibrary" class="pattern-library"></div>
|
||||
</div>
|
||||
|
||||
@ -378,7 +429,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/app.js?v=3"></script>
|
||||
<!-- Pattern File Viewer -->
|
||||
<div class="modal-overlay" id="patternFileViewerOverlay">
|
||||
<div class="viewer-shell">
|
||||
<div class="viewer-header">
|
||||
<button class="viewer-back-btn" onclick="closePatternFileViewer()" title="Back to counter">
|
||||
<i class="fa-solid fa-arrow-left"></i> Counter
|
||||
</button>
|
||||
<span id="viewerPatternName" class="viewer-title"></span>
|
||||
<button class="viewer-close" onclick="closePatternFileViewer()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="viewer-stage" id="viewerStage">
|
||||
<div id="viewerBook" class="viewer-book"></div>
|
||||
<div class="viewer-loading" id="viewerLoading">
|
||||
<div class="import-spinner"></div>
|
||||
<p>Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewer-counter-bar" id="viewerCounterBar" style="display:none">
|
||||
<div class="viewer-counter-info">
|
||||
<span id="viewerCounterProject" class="viewer-counter-project"></span>
|
||||
<i class="fa-solid fa-chevron-right viewer-counter-sep"></i>
|
||||
<span id="viewerCounterPart" class="viewer-counter-part"></span>
|
||||
</div>
|
||||
<div class="viewer-counter-btns">
|
||||
<button class="viewer-count-btn" id="viewerCountMinus" onclick="viewerCountChange(-1)">−</button>
|
||||
<span id="viewerCountDisplay" class="viewer-count-display">0</span>
|
||||
<button class="viewer-count-btn" id="viewerCountPlus" onclick="viewerCountChange(1)">+</button>
|
||||
</div>
|
||||
<button class="viewer-goto-counter" onclick="closePatternFileViewer()" title="Back to counter">
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="viewer-controls">
|
||||
<button class="viewer-nav-btn" id="viewerPrevBtn" onclick="viewerNavigate(-1)">‹</button>
|
||||
<span id="viewerPageInfo" class="viewer-page-info" onclick="viewerJumpToPage()" title="Tap to jump to page" style="cursor:pointer"></span>
|
||||
<button class="viewer-nav-btn" id="viewerNextBtn" onclick="viewerNavigate(1)">›</button>
|
||||
<div class="viewer-zoom-controls">
|
||||
<button class="viewer-zoom-btn" onclick="viewerZoomChange(-0.25)" title="Zoom out">−</button>
|
||||
<span id="viewerZoomDisplay" class="viewer-zoom-display">100%</span>
|
||||
<button class="viewer-zoom-btn" onclick="viewerZoomChange(0.25)" title="Zoom in">+</button>
|
||||
<button class="viewer-zoom-btn viewer-zoom-reset" onclick="viewerZoomReset()" title="Reset zoom">⊡</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/app.js?v=9"></script>
|
||||
<footer class="footer-bg" aria-hidden="true"></footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
30
server/package-lock.json
generated
30
server/package-lock.json
generated
@ -8,9 +8,12 @@
|
||||
"name": "toadstooltally-backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pg": "^8.12.0",
|
||||
"sharp": "^0.33.3",
|
||||
"uuid": "^9.0.1"
|
||||
@ -576,6 +579,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@ -659,6 +671,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@ -2075,6 +2096,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||
|
||||
@ -9,11 +9,14 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sharp": "^0.33.3",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pg": "^8.12.0",
|
||||
"sharp": "^0.33.3",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
const { Pool } = require('pg');
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
const SHA256_RE = /^[0-9a-f]{64}$/;
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
@ -100,14 +104,23 @@ async function withClient(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function hashPassword(pw) {
|
||||
return crypto.createHash('sha256').update(pw).digest('hex');
|
||||
async function hashPassword(pw) {
|
||||
return bcrypt.hash(pw, BCRYPT_ROUNDS);
|
||||
}
|
||||
|
||||
async function verifyPassword(pw, stored) {
|
||||
if (SHA256_RE.test(stored)) {
|
||||
// Legacy SHA-256 hash — compare and signal rehash needed
|
||||
const legacy = crypto.createHash('sha256').update(pw).digest('hex');
|
||||
return legacy === stored ? 'rehash' : false;
|
||||
}
|
||||
return bcrypt.compare(pw, stored) ? 'ok' : false;
|
||||
}
|
||||
|
||||
async function createUser(email, password) {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const id = crypto.randomUUID();
|
||||
const password_hash = hashPassword(password);
|
||||
const password_hash = await hashPassword(password);
|
||||
const existing = await pool.query(
|
||||
`select id, email, display_name, note, is_admin, status from users where lower(email)=$1`,
|
||||
[normalizedEmail]
|
||||
@ -134,7 +147,12 @@ async function verifyUser(email, password) {
|
||||
const row = await pool.query(`select * from users where lower(email)=$1`, [normalizedEmail]);
|
||||
if (!row.rows.length) return null;
|
||||
const user = row.rows[0];
|
||||
if (user.password_hash !== hashPassword(password)) return null;
|
||||
const result = await verifyPassword(password, user.password_hash);
|
||||
if (!result) return null;
|
||||
if (result === 'rehash') {
|
||||
const newHash = await hashPassword(password);
|
||||
await pool.query(`update users set password_hash=$2, updated_at=now() where id=$1`, [user.id, newHash]);
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
return { pending: true, status: user.status };
|
||||
}
|
||||
@ -270,7 +288,7 @@ async function setUserAdmin(userId, isAdmin) {
|
||||
|
||||
async function bootstrapAdmin(email, password) {
|
||||
if (!email || !password) return;
|
||||
const hash = hashPassword(password);
|
||||
const hash = await hashPassword(password);
|
||||
const existing = await pool.query(`select id from users where email=$1`, [email]);
|
||||
if (existing.rows.length) {
|
||||
await pool.query(`update users set password_hash=$2, is_admin=true, status='active', updated_at=now() where email=$1`, [email, hash]);
|
||||
@ -289,7 +307,7 @@ async function listAllUsers() {
|
||||
}
|
||||
|
||||
async function setPassword(userId, newPassword) {
|
||||
const hash = hashPassword(newPassword);
|
||||
const hash = await hashPassword(newPassword);
|
||||
await pool.query(`update users set password_hash=$2, updated_at=now() where id=$1`, [userId, hash]);
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const path = require('path');
|
||||
const nodemailer = require('nodemailer');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const sharp = require('sharp');
|
||||
@ -35,6 +36,31 @@ const app = express();
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
|
||||
|
||||
const mailer = process.env.SMTP_HOST
|
||||
? nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
})
|
||||
: null;
|
||||
|
||||
async function sendResetEmail(toEmail, token, appUrl) {
|
||||
const resetUrl = `${appUrl}?reset=${encodeURIComponent(token)}`;
|
||||
if (!mailer) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[PASSWORD RESET] Token for ${toEmail}: ${token}`);
|
||||
return;
|
||||
}
|
||||
await mailer.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to: toEmail,
|
||||
subject: 'Reset your Toadstool Tally password',
|
||||
text: `Click the link below to reset your password (expires in 1 hour):\n\n${resetUrl}\n\nIf you didn't request this, ignore this email.`,
|
||||
html: `<p>Click the link below to reset your password (expires in 1 hour):</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>If you didn't request this, ignore this email.</p>`
|
||||
});
|
||||
}
|
||||
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
app.use(cors());
|
||||
@ -204,24 +230,152 @@ app.get('/share/:token', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Upload route: save to /uploads, return URL (Optimized with sharp, fallback to raw)
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); // Bumped limit slightly
|
||||
// Upload route: save to /uploads, return URL (images optimised with sharp; PDF/EPUB stored as-is)
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'File required' });
|
||||
const filename = `${Date.now()}-${uuid()}.webp`;
|
||||
const outPath = path.join(UPLOAD_DIR, filename);
|
||||
await sharp(req.file.buffer)
|
||||
.rotate() // Auto-rotate based on EXIF
|
||||
const { mimetype, buffer, originalname } = req.file;
|
||||
const ext = (originalname || '').split('.').pop().toLowerCase();
|
||||
const id = `${Date.now()}-${uuid()}`;
|
||||
|
||||
if (mimetype === 'application/pdf' || ext === 'pdf') {
|
||||
const filename = `${id}.pdf`;
|
||||
await fs.promises.writeFile(path.join(UPLOAD_DIR, filename), buffer);
|
||||
return res.json({ url: `/uploads/${filename}`, type: 'pdf' });
|
||||
}
|
||||
if (mimetype === 'application/epub+zip' || ext === 'epub') {
|
||||
const filename = `${id}.epub`;
|
||||
await fs.promises.writeFile(path.join(UPLOAD_DIR, filename), buffer);
|
||||
return res.json({ url: `/uploads/${filename}`, type: 'epub' });
|
||||
}
|
||||
|
||||
// Default: image — optimise with sharp
|
||||
const filename = `${id}.webp`;
|
||||
await sharp(buffer)
|
||||
.rotate()
|
||||
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.toFile(outPath);
|
||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||||
const url = `${baseUrl}/uploads/${filename}`;
|
||||
res.json({ url });
|
||||
.toFile(path.join(UPLOAD_DIR, filename));
|
||||
return res.json({ url: `/uploads/${filename}`, type: 'image' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Image processing failed' });
|
||||
res.status(500).json({ error: 'Upload failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// EPUB content extraction — returns HTML pages array
|
||||
app.get('/api/epub-content', async (req, res) => {
|
||||
try {
|
||||
const fileUrl = req.query.url || '';
|
||||
const filename = path.basename(new URL(fileUrl, 'http://localhost').pathname);
|
||||
if (!filename.endsWith('.epub')) return res.status(400).json({ error: 'Not an EPUB file' });
|
||||
const filePath = path.resolve(UPLOAD_DIR, filename);
|
||||
if (!filePath.startsWith(path.resolve(UPLOAD_DIR))) return res.status(403).json({ error: 'Access denied' });
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
const AdmZip = require('adm-zip');
|
||||
const zip = new AdmZip(filePath);
|
||||
|
||||
// Build a case-insensitive entry lookup map
|
||||
const entryMap = {};
|
||||
for (const entry of zip.getEntries()) {
|
||||
entryMap[entry.entryName.toLowerCase()] = entry;
|
||||
}
|
||||
const readEntry = (name) => {
|
||||
const norm = name.replace(/\\/g, '/').replace(/^\//, '');
|
||||
const entry = entryMap[norm.toLowerCase()];
|
||||
if (!entry) return null;
|
||||
return entry.getData().toString('utf8');
|
||||
};
|
||||
const readEntryBuf = (name) => {
|
||||
const norm = name.replace(/\\/g, '/').replace(/^\//, '');
|
||||
const entry = entryMap[norm.toLowerCase()];
|
||||
return entry ? entry.getData() : null;
|
||||
};
|
||||
|
||||
// Locate OPF via container.xml
|
||||
const containerXml = readEntry('META-INF/container.xml');
|
||||
if (!containerXml) return res.status(422).json({ error: 'Invalid EPUB: no META-INF/container.xml' });
|
||||
const opfMatch = containerXml.match(/full-path="([^"]+)"/);
|
||||
if (!opfMatch) return res.status(422).json({ error: 'Invalid EPUB: no OPF path in container.xml' });
|
||||
|
||||
const opfPath = opfMatch[1].replace(/\\/g, '/').replace(/^\//, '');
|
||||
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : '';
|
||||
const opfContent = readEntry(opfPath);
|
||||
if (!opfContent) return res.status(422).json({ error: 'Invalid EPUB: cannot read OPF file' });
|
||||
|
||||
// Build manifest id→href map (handle self-closing and both attribute orderings)
|
||||
const manifest = {};
|
||||
for (const m of opfContent.matchAll(/<item\b([^>]*?)\/?>$/gim)) {
|
||||
const attrs = m[1];
|
||||
const idM = attrs.match(/\bid="([^"]+)"/i);
|
||||
const hrefM = attrs.match(/\bhref="([^"]+)"/i);
|
||||
if (idM && hrefM) manifest[idM[1]] = hrefM[1];
|
||||
}
|
||||
// Fallback: simpler match
|
||||
if (Object.keys(manifest).length === 0) {
|
||||
for (const m of opfContent.matchAll(/<item[^>]+>/gi)) {
|
||||
const tag = m[0];
|
||||
const idM = tag.match(/\bid="([^"]+)"/i);
|
||||
const hrefM = tag.match(/\bhref="([^"]+)"/i);
|
||||
if (idM && hrefM) manifest[idM[1]] = hrefM[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Spine gives reading order
|
||||
const spine = [];
|
||||
for (const m of opfContent.matchAll(/<itemref\b[^>]*\bidref="([^"]+)"/gi)) {
|
||||
if (manifest[m[1]]) spine.push(manifest[m[1]]);
|
||||
}
|
||||
if (spine.length === 0) return res.status(422).json({ error: 'EPUB has no spine items' });
|
||||
|
||||
const joinPath = (base, href) => {
|
||||
if (!base) return href.replace(/^\//, '');
|
||||
// Handle ../ relative paths
|
||||
const parts = (base + '/' + href).split('/');
|
||||
const resolved = [];
|
||||
for (const p of parts) {
|
||||
if (p === '..') resolved.pop();
|
||||
else if (p !== '.') resolved.push(p);
|
||||
}
|
||||
return resolved.join('/');
|
||||
};
|
||||
|
||||
const pages = spine.map(href => {
|
||||
try {
|
||||
const fullPath = joinPath(opfDir, href);
|
||||
const html = readEntry(fullPath);
|
||||
if (!html) return '';
|
||||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
||||
let content = bodyMatch ? bodyMatch[1] : html;
|
||||
|
||||
// Inline images as base64 so they render inside a sandboxed iframe
|
||||
content = content.replace(/\bsrc="([^"]+)"/gi, (_, imgSrc) => {
|
||||
if (imgSrc.startsWith('data:') || imgSrc.startsWith('http')) return `src="${imgSrc}"`;
|
||||
const imgPath = joinPath(fullPath.includes('/') ? fullPath.substring(0, fullPath.lastIndexOf('/')) : '', decodeURIComponent(imgSrc));
|
||||
try {
|
||||
const buf = readEntryBuf(imgPath);
|
||||
if (!buf) return 'src=""';
|
||||
const imgExt = imgSrc.split('?')[0].split('.').pop().toLowerCase();
|
||||
const mime = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml' }[imgExt] || 'image/jpeg';
|
||||
return `src="data:${mime};base64,${buf.toString('base64')}"`;
|
||||
} catch { return 'src=""'; }
|
||||
});
|
||||
|
||||
// Strip scripts and event handlers
|
||||
content = content
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/\bon\w+="[^"]*"/gi, '');
|
||||
return content.trim();
|
||||
} catch { return ''; }
|
||||
}).filter(p => p.length > 10);
|
||||
|
||||
if (pages.length === 0) return res.status(422).json({ error: 'No readable content extracted from EPUB' });
|
||||
res.json({ pages });
|
||||
} catch (err) {
|
||||
console.error('epub-content error:', err);
|
||||
res.status(500).json({ error: 'Could not read EPUB: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
@ -272,16 +426,15 @@ app.get('/api/admin/users', requireAuth, requireAdmin, async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Password reset request (demo: returns token, logs it)
|
||||
app.post('/api/password-reset/request', async (req, res) => {
|
||||
const { email } = req.body || {};
|
||||
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||
try {
|
||||
const userRow = await getUserByEmail(email);
|
||||
if (!userRow) return res.json({ ok: true }); // don't leak
|
||||
if (!userRow) return res.json({ ok: true }); // don't leak whether account exists
|
||||
const reset = await createResetToken(userRow.id);
|
||||
console.log(`Password reset token for ${email}: ${reset.token}`);
|
||||
// TODO: send via mail backend
|
||||
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
await sendResetEmail(email, reset.token, appUrl);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
7
sw.js
7
sw.js
@ -1,9 +1,12 @@
|
||||
const CACHE_NAME = 'toadstool-tally-v2';
|
||||
const CACHE_NAME = 'toadstool-tally-v11';
|
||||
const ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/assets/style.css',
|
||||
'/assets/app.js?v=3',
|
||||
'/assets/app.js',
|
||||
'/assets/pdfjs/pdf.min.js',
|
||||
'/assets/pdfjs/pdf.worker.min.js',
|
||||
'/assets/page-flip.browser.js',
|
||||
'/assets/textures/mushroom.svg',
|
||||
'/assets/icons/favicon-96x96.png',
|
||||
'/assets/icons/favicon.ico',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user