@autumnsgrove/groveengine 0.9.3 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/admin/GutterManager.svelte +314 -35
- package/dist/components/admin/MarkdownEditor.svelte +105 -40
- package/dist/components/admin/MarkdownEditor.svelte.d.ts +2 -0
- package/dist/components/custom/ContentWithGutter.svelte +12 -2
- package/dist/components/custom/GutterItem.svelte +122 -47
- package/dist/components/custom/TableOfContents.svelte +36 -3
- package/dist/components/quota/UpgradePrompt.svelte +12 -16
- package/dist/config/index.d.ts +4 -3
- package/dist/config/index.js +4 -3
- package/dist/config/tiers.d.ts +134 -0
- package/dist/config/tiers.js +402 -0
- package/dist/groveauth/types.d.ts +4 -1
- package/dist/groveauth/types.js +9 -13
- package/dist/payments/shop.d.ts +3 -3
- package/dist/payments/shop.js +271 -95
- package/dist/server/billing.d.ts +29 -0
- package/dist/server/billing.js +80 -0
- package/dist/server/env-validation.d.ts +68 -0
- package/dist/server/env-validation.js +95 -0
- package/dist/server/rate-limits/config.d.ts +24 -96
- package/dist/server/rate-limits/config.js +35 -59
- package/dist/server/rate-limits/tenant.d.ts +1 -69
- package/dist/server/services/database.d.ts +1 -1
- package/dist/server/services/database.js +69 -59
- package/dist/server/services/storage.d.ts +2 -1
- package/dist/server/services/storage.js +115 -91
- package/dist/server/tier-features.d.ts +56 -0
- package/dist/server/tier-features.js +79 -0
- package/dist/ui/components/chrome/Footer.svelte +16 -2
- package/dist/ui/components/chrome/Header.svelte +15 -19
- package/dist/ui/components/chrome/Header.svelte.d.ts +2 -2
- package/dist/ui/components/chrome/ThemeToggle.svelte +86 -27
- package/dist/ui/components/icons/index.d.ts +3 -3
- package/dist/ui/components/icons/index.js +6 -6
- package/dist/ui/components/icons/lucide.d.ts +15 -2
- package/dist/ui/components/icons/lucide.js +15 -3
- package/dist/ui/components/nature/GroveDivider.svelte +24 -28
- package/dist/ui/components/nature/GroveDivider.svelte.d.ts +5 -7
- package/dist/ui/components/nature/{Logo.svelte.d.ts → LogoArchive.svelte.d.ts} +3 -3
- package/dist/ui/components/nature/creatures/Bee.svelte +2 -2
- package/dist/ui/components/nature/creatures/Butterfly.svelte +3 -3
- package/dist/ui/components/nature/creatures/Owl.svelte +2 -2
- package/dist/ui/components/nature/ground/FlowerWild.svelte +3 -3
- package/dist/ui/components/nature/index.d.ts +12 -11
- package/dist/ui/components/nature/index.js +14 -12
- package/dist/ui/components/nature/palette.d.ts +106 -10
- package/dist/ui/components/nature/palette.js +211 -147
- package/dist/ui/components/nature/sky/Sun.svelte +2 -2
- package/dist/ui/components/nature/structural/LatticeWithVine.svelte +2 -2
- package/dist/ui/components/nature/water/LilyPad.svelte +2 -2
- package/dist/ui/components/ui/GlassLogo.svelte +354 -300
- package/dist/ui/components/ui/GlassLogo.svelte.d.ts +76 -13
- package/dist/ui/components/ui/GlassLogoArchive.svelte +415 -0
- package/dist/ui/components/ui/GlassLogoArchive.svelte.d.ts +23 -0
- package/dist/ui/components/ui/Logo.svelte +269 -169
- package/dist/ui/components/ui/Logo.svelte.d.ts +93 -14
- package/dist/ui/components/ui/LogoArchive.svelte +220 -0
- package/dist/ui/components/ui/LogoArchive.svelte.d.ts +20 -0
- package/dist/ui/components/ui/LogoLoader.svelte +1 -1
- package/dist/ui/components/ui/index.d.ts +30 -28
- package/dist/ui/components/ui/index.js +31 -29
- package/dist/ui/stores/season.d.ts +12 -2
- package/dist/ui/stores/season.js +101 -18
- package/dist/ui/types/index.d.ts +6 -0
- package/dist/ui/types/index.js +7 -0
- package/dist/ui/types/season.d.ts +36 -0
- package/dist/ui/types/season.js +86 -0
- package/dist/ui/utils/color.d.ts +69 -0
- package/dist/ui/utils/color.js +155 -0
- package/dist/ui/utils/index.d.ts +2 -1
- package/dist/ui/utils/index.js +5 -2
- package/package.json +22 -20
- package/static/favicon.png +0 -0
- package/static/favicon.svg +6 -0
- package/static/fonts/alagard.ttf +0 -0
- package/LICENSE +0 -378
- /package/dist/ui/components/nature/{Logo.svelte → LogoArchive.svelte} +0 -0
|
@@ -30,6 +30,16 @@
|
|
|
30
30
|
* @property {string} url
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Object} ProcessedAnchor
|
|
35
|
+
* @property {string} raw - Original anchor string
|
|
36
|
+
* @property {boolean} isHeading - Whether this is a heading anchor
|
|
37
|
+
* @property {number} headingLevel - Heading level (1-6) or 0 if not a heading
|
|
38
|
+
* @property {boolean} isAnchorTag - Whether this is a custom anchor tag
|
|
39
|
+
* @property {string} displayText - Human-readable display text
|
|
40
|
+
* @property {string} type - Anchor type for accessibility labels
|
|
41
|
+
*/
|
|
42
|
+
|
|
33
43
|
// Props
|
|
34
44
|
let {
|
|
35
45
|
gutterItems = $bindable(/** @type {GutterItem[]} */ ([])),
|
|
@@ -37,6 +47,62 @@
|
|
|
37
47
|
availableAnchors = /** @type {string[]} */ ([]),
|
|
38
48
|
} = $props();
|
|
39
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Extract heading level from an anchor string (capped at 1-6)
|
|
52
|
+
* @param {string | undefined} anchor
|
|
53
|
+
* @returns {number} Heading level 1-6, or 0 if not a heading
|
|
54
|
+
*/
|
|
55
|
+
function getHeadingLevel(anchor) {
|
|
56
|
+
if (!anchor) return 0;
|
|
57
|
+
// Only match valid heading levels (1-6 hash marks)
|
|
58
|
+
const match = anchor.match(/^#{1,6}/);
|
|
59
|
+
return match ? Math.min(match[0].length, 6) : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Process a raw anchor string into structured data
|
|
64
|
+
* @param {string} anchor - The raw anchor string
|
|
65
|
+
* @returns {ProcessedAnchor}
|
|
66
|
+
*/
|
|
67
|
+
function createProcessedAnchor(anchor) {
|
|
68
|
+
const isHeading = anchor.startsWith('#');
|
|
69
|
+
const headingLevel = getHeadingLevel(anchor);
|
|
70
|
+
const isAnchorTag = anchor.startsWith('anchor:');
|
|
71
|
+
const displayText = isHeading
|
|
72
|
+
? anchor.replace(/^#+\s*/, '')
|
|
73
|
+
: anchor.replace('anchor:', '');
|
|
74
|
+
const type = isHeading
|
|
75
|
+
? `heading level ${headingLevel}`
|
|
76
|
+
: isAnchorTag
|
|
77
|
+
? 'anchor tag'
|
|
78
|
+
: 'paragraph';
|
|
79
|
+
|
|
80
|
+
return { raw: anchor, isHeading, headingLevel, isAnchorTag, displayText, type };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Preprocess anchors into structured data for better performance
|
|
85
|
+
* @type {ProcessedAnchor[]}
|
|
86
|
+
*/
|
|
87
|
+
let processedAnchors = $derived(availableAnchors.map(createProcessedAnchor));
|
|
88
|
+
|
|
89
|
+
/** Default empty anchor for fallback */
|
|
90
|
+
const emptyAnchor = { raw: '', isHeading: false, headingLevel: 0, isAnchorTag: false, displayText: '', type: 'paragraph' };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get processed anchor data for display (checks cache first, then computes)
|
|
94
|
+
* @param {string | undefined} anchor
|
|
95
|
+
* @returns {ProcessedAnchor}
|
|
96
|
+
*/
|
|
97
|
+
function getProcessedAnchor(anchor) {
|
|
98
|
+
if (!anchor) return emptyAnchor;
|
|
99
|
+
// Try to find in preprocessed cache first
|
|
100
|
+
const cached = processedAnchors.find(pa => pa.raw === anchor);
|
|
101
|
+
if (cached) return cached;
|
|
102
|
+
// Fallback to computing for anchors not in availableAnchors
|
|
103
|
+
return createProcessedAnchor(anchor);
|
|
104
|
+
}
|
|
105
|
+
|
|
40
106
|
// State
|
|
41
107
|
let showAddModal = $state(false);
|
|
42
108
|
/** @type {number | null} */
|
|
@@ -267,11 +333,12 @@
|
|
|
267
333
|
<p class="hint">Add comments, images, or galleries that appear alongside your content.</p>
|
|
268
334
|
</div>
|
|
269
335
|
{:else}
|
|
270
|
-
<div class="vines-list">
|
|
336
|
+
<div class="vines-list" role="list" aria-label="Vine items">
|
|
271
337
|
{#each gutterItems as item, index (index)}
|
|
272
|
-
|
|
338
|
+
{@const anchor = getProcessedAnchor(item.anchor)}
|
|
339
|
+
<div class="vine-item" role="listitem">
|
|
273
340
|
<div class="item-header">
|
|
274
|
-
<span class="item-type">
|
|
341
|
+
<span class="item-type" aria-hidden="true">
|
|
275
342
|
{#if item.type === "comment"}
|
|
276
343
|
<MessageSquare class="type-icon" />
|
|
277
344
|
{:else if item.type === "photo"}
|
|
@@ -282,7 +349,21 @@
|
|
|
282
349
|
<Pin class="type-icon" />
|
|
283
350
|
{/if}
|
|
284
351
|
</span>
|
|
285
|
-
<
|
|
352
|
+
<div class="item-anchor-display">
|
|
353
|
+
{#if item.anchor}
|
|
354
|
+
{#if anchor.isHeading}
|
|
355
|
+
<span class="anchor-badge heading-badge" aria-hidden="true">H{anchor.headingLevel}</span>
|
|
356
|
+
{:else if anchor.isAnchorTag}
|
|
357
|
+
<span class="anchor-badge tag-badge" aria-hidden="true">⚓</span>
|
|
358
|
+
{:else}
|
|
359
|
+
<span class="anchor-badge para-badge" aria-hidden="true">¶</span>
|
|
360
|
+
{/if}
|
|
361
|
+
<span class="item-anchor-text" title={item.anchor}>{anchor.displayText}</span>
|
|
362
|
+
<span class="visually-hidden">Anchored to {anchor.type}: {anchor.displayText}</span>
|
|
363
|
+
{:else}
|
|
364
|
+
<span class="no-anchor-warning" role="alert">⚠ No anchor set</span>
|
|
365
|
+
{/if}
|
|
366
|
+
</div>
|
|
286
367
|
<div class="item-actions">
|
|
287
368
|
<button
|
|
288
369
|
class="action-btn"
|
|
@@ -364,18 +445,40 @@
|
|
|
364
445
|
</span>
|
|
365
446
|
</div>
|
|
366
447
|
|
|
367
|
-
{#if
|
|
368
|
-
<div class="available-anchors">
|
|
369
|
-
<span class="anchors-label">
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
448
|
+
{#if processedAnchors.length > 0}
|
|
449
|
+
<div class="available-anchors-section">
|
|
450
|
+
<span class="anchors-label" id="anchor-selection-label">Click to select anchor location:</span>
|
|
451
|
+
<div class="anchor-list" role="listbox" aria-labelledby="anchor-selection-label">
|
|
452
|
+
{#each processedAnchors as anchor}
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
class="anchor-option"
|
|
456
|
+
class:selected={itemAnchor === anchor.raw}
|
|
457
|
+
class:heading={anchor.isHeading}
|
|
458
|
+
class:anchor-tag={anchor.isAnchorTag}
|
|
459
|
+
role="option"
|
|
460
|
+
aria-selected={itemAnchor === anchor.raw}
|
|
461
|
+
aria-label="Select {anchor.type}: {anchor.displayText}"
|
|
462
|
+
onclick={() => (itemAnchor = anchor.raw)}
|
|
463
|
+
>
|
|
464
|
+
{#if anchor.isHeading}
|
|
465
|
+
<span class="anchor-icon heading-icon" aria-hidden="true">H{anchor.headingLevel}</span>
|
|
466
|
+
{:else if anchor.isAnchorTag}
|
|
467
|
+
<span class="anchor-icon tag-icon" aria-hidden="true">⚓</span>
|
|
468
|
+
{:else}
|
|
469
|
+
<span class="anchor-icon para-icon" aria-hidden="true">¶</span>
|
|
470
|
+
{/if}
|
|
471
|
+
<span class="anchor-text">{anchor.displayText}</span>
|
|
472
|
+
{#if itemAnchor === anchor.raw}
|
|
473
|
+
<span class="selected-check" aria-hidden="true">✓</span>
|
|
474
|
+
{/if}
|
|
475
|
+
</button>
|
|
476
|
+
{/each}
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
{:else}
|
|
480
|
+
<div class="no-anchors-hint">
|
|
481
|
+
<p>No anchors found. Add headings to your content or use "Add Anchor" to create custom anchor points.</p>
|
|
379
482
|
</div>
|
|
380
483
|
{/if}
|
|
381
484
|
|
|
@@ -636,20 +739,76 @@
|
|
|
636
739
|
height: 1rem;
|
|
637
740
|
}
|
|
638
741
|
|
|
639
|
-
.item-anchor {
|
|
742
|
+
.item-anchor-display {
|
|
640
743
|
flex: 1;
|
|
641
|
-
|
|
642
|
-
|
|
744
|
+
display: flex;
|
|
745
|
+
align-items: center;
|
|
746
|
+
gap: 0.35rem;
|
|
747
|
+
min-width: 0;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
.anchor-badge {
|
|
751
|
+
display: inline-flex;
|
|
752
|
+
align-items: center;
|
|
753
|
+
justify-content: center;
|
|
754
|
+
min-width: 20px;
|
|
755
|
+
height: 16px;
|
|
756
|
+
font-size: 0.6rem;
|
|
757
|
+
font-weight: 700;
|
|
758
|
+
border-radius: 3px;
|
|
759
|
+
flex-shrink: 0;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.heading-badge {
|
|
763
|
+
background: rgba(124, 77, 171, 0.15);
|
|
764
|
+
color: #7c4dab;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
:global(.dark) .heading-badge {
|
|
768
|
+
background: rgba(201, 160, 232, 0.15);
|
|
769
|
+
color: #c9a0e8;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.tag-badge {
|
|
773
|
+
background: rgba(59, 130, 246, 0.15);
|
|
774
|
+
color: #3b82f6;
|
|
775
|
+
font-size: 0.65rem;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
:global(.dark) .tag-badge {
|
|
779
|
+
background: rgba(96, 165, 250, 0.15);
|
|
780
|
+
color: #60a5fa;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
.para-badge {
|
|
784
|
+
background: rgba(107, 114, 128, 0.15);
|
|
785
|
+
color: #6b7280;
|
|
786
|
+
font-size: 0.65rem;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
.item-anchor-text {
|
|
790
|
+
font-family: -apple-system, system-ui, sans-serif;
|
|
791
|
+
font-size: 0.75rem;
|
|
643
792
|
color: var(--color-text-muted);
|
|
644
793
|
white-space: nowrap;
|
|
645
794
|
overflow: hidden;
|
|
646
795
|
text-overflow: ellipsis;
|
|
647
796
|
}
|
|
648
797
|
|
|
649
|
-
:global(.dark) .item-anchor {
|
|
798
|
+
:global(.dark) .item-anchor-text {
|
|
650
799
|
color: var(--grove-text-strong);
|
|
651
800
|
}
|
|
652
801
|
|
|
802
|
+
.no-anchor-warning {
|
|
803
|
+
font-size: 0.7rem;
|
|
804
|
+
color: #e07030;
|
|
805
|
+
font-style: italic;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
:global(.dark) .no-anchor-warning {
|
|
809
|
+
color: #f0c674;
|
|
810
|
+
}
|
|
811
|
+
|
|
653
812
|
.item-actions {
|
|
654
813
|
display: flex;
|
|
655
814
|
gap: 0.125rem;
|
|
@@ -775,40 +934,147 @@
|
|
|
775
934
|
flex: 1;
|
|
776
935
|
}
|
|
777
936
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
flex-wrap: wrap;
|
|
781
|
-
gap: 0.35rem;
|
|
782
|
-
align-items: center;
|
|
937
|
+
/* Improved anchor selection UI */
|
|
938
|
+
.available-anchors-section {
|
|
783
939
|
margin-bottom: 1rem;
|
|
940
|
+
background: var(--grove-overlay-5);
|
|
941
|
+
border: 1px solid var(--grove-border-subtle);
|
|
942
|
+
border-radius: 10px;
|
|
943
|
+
padding: 0.75rem;
|
|
784
944
|
}
|
|
785
945
|
|
|
786
946
|
.anchors-label {
|
|
947
|
+
display: block;
|
|
787
948
|
font-size: 0.75rem;
|
|
788
949
|
color: var(--color-text-subtle);
|
|
950
|
+
margin-bottom: 0.5rem;
|
|
951
|
+
font-weight: 500;
|
|
789
952
|
}
|
|
790
953
|
|
|
791
|
-
.anchor-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
954
|
+
.anchor-list {
|
|
955
|
+
display: flex;
|
|
956
|
+
flex-direction: column;
|
|
957
|
+
gap: 0.35rem;
|
|
958
|
+
max-height: 200px;
|
|
959
|
+
overflow-y: auto;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
.anchor-option {
|
|
963
|
+
display: flex;
|
|
964
|
+
align-items: center;
|
|
965
|
+
gap: 0.5rem;
|
|
966
|
+
padding: 0.5rem 0.75rem;
|
|
967
|
+
background: var(--glass-bg-medium, rgba(255, 255, 255, 0.5));
|
|
968
|
+
backdrop-filter: blur(8px);
|
|
969
|
+
-webkit-backdrop-filter: blur(8px);
|
|
970
|
+
border: 1px solid var(--grove-border-subtle);
|
|
971
|
+
border-radius: 8px;
|
|
796
972
|
color: var(--color-text-muted);
|
|
797
|
-
font-size: 0.
|
|
798
|
-
font-family: monospace;
|
|
973
|
+
font-size: 0.8rem;
|
|
799
974
|
cursor: pointer;
|
|
800
975
|
transition: all 0.15s ease;
|
|
976
|
+
text-align: left;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.anchor-option:hover {
|
|
980
|
+
background: var(--grove-overlay-15);
|
|
981
|
+
border-color: var(--grove-overlay-25);
|
|
982
|
+
transform: translateX(4px);
|
|
801
983
|
}
|
|
802
984
|
|
|
803
|
-
.anchor-
|
|
804
|
-
background:
|
|
985
|
+
.anchor-option.selected {
|
|
986
|
+
background: rgba(34, 197, 94, 0.15);
|
|
987
|
+
border-color: rgba(34, 197, 94, 0.4);
|
|
805
988
|
color: var(--color-primary);
|
|
806
989
|
}
|
|
807
990
|
|
|
808
|
-
:global(.dark) .anchor-
|
|
991
|
+
:global(.dark) .anchor-option {
|
|
992
|
+
background: rgba(16, 50, 37, 0.35);
|
|
993
|
+
border-color: rgba(74, 222, 128, 0.1);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
:global(.dark) .anchor-option:hover {
|
|
997
|
+
background: rgba(16, 50, 37, 0.5);
|
|
998
|
+
border-color: rgba(74, 222, 128, 0.2);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
:global(.dark) .anchor-option.selected {
|
|
1002
|
+
background: rgba(74, 222, 128, 0.15);
|
|
1003
|
+
border-color: rgba(74, 222, 128, 0.4);
|
|
809
1004
|
color: #86efac;
|
|
810
1005
|
}
|
|
811
1006
|
|
|
1007
|
+
.anchor-icon {
|
|
1008
|
+
display: inline-flex;
|
|
1009
|
+
align-items: center;
|
|
1010
|
+
justify-content: center;
|
|
1011
|
+
min-width: 24px;
|
|
1012
|
+
height: 20px;
|
|
1013
|
+
font-size: 0.65rem;
|
|
1014
|
+
font-weight: 700;
|
|
1015
|
+
border-radius: 4px;
|
|
1016
|
+
flex-shrink: 0;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.heading-icon {
|
|
1020
|
+
background: rgba(124, 77, 171, 0.15);
|
|
1021
|
+
color: #7c4dab;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
:global(.dark) .heading-icon {
|
|
1025
|
+
background: rgba(201, 160, 232, 0.15);
|
|
1026
|
+
color: #c9a0e8;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
.tag-icon {
|
|
1030
|
+
background: rgba(59, 130, 246, 0.15);
|
|
1031
|
+
color: #3b82f6;
|
|
1032
|
+
font-size: 0.75rem;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
:global(.dark) .tag-icon {
|
|
1036
|
+
background: rgba(96, 165, 250, 0.15);
|
|
1037
|
+
color: #60a5fa;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
.para-icon {
|
|
1041
|
+
background: rgba(107, 114, 128, 0.15);
|
|
1042
|
+
color: #6b7280;
|
|
1043
|
+
font-size: 0.75rem;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.anchor-text {
|
|
1047
|
+
flex: 1;
|
|
1048
|
+
overflow: hidden;
|
|
1049
|
+
text-overflow: ellipsis;
|
|
1050
|
+
white-space: nowrap;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.selected-check {
|
|
1054
|
+
color: var(--color-primary);
|
|
1055
|
+
font-weight: 600;
|
|
1056
|
+
flex-shrink: 0;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
:global(.dark) .selected-check {
|
|
1060
|
+
color: #86efac;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.no-anchors-hint {
|
|
1064
|
+
padding: 1rem;
|
|
1065
|
+
background: var(--grove-overlay-5);
|
|
1066
|
+
border: 1px dashed var(--grove-border);
|
|
1067
|
+
border-radius: 8px;
|
|
1068
|
+
margin-bottom: 1rem;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
.no-anchors-hint p {
|
|
1072
|
+
margin: 0;
|
|
1073
|
+
font-size: 0.8rem;
|
|
1074
|
+
color: var(--color-text-subtle);
|
|
1075
|
+
text-align: center;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
812
1078
|
.image-preview {
|
|
813
1079
|
margin-top: 0.5rem;
|
|
814
1080
|
max-height: 150px;
|
|
@@ -960,4 +1226,17 @@
|
|
|
960
1226
|
overflow: hidden;
|
|
961
1227
|
text-overflow: ellipsis;
|
|
962
1228
|
}
|
|
1229
|
+
|
|
1230
|
+
/* Screen reader only utility */
|
|
1231
|
+
.visually-hidden {
|
|
1232
|
+
position: absolute;
|
|
1233
|
+
width: 1px;
|
|
1234
|
+
height: 1px;
|
|
1235
|
+
padding: 0;
|
|
1236
|
+
margin: -1px;
|
|
1237
|
+
overflow: hidden;
|
|
1238
|
+
clip: rect(0, 0, 0, 0);
|
|
1239
|
+
white-space: nowrap;
|
|
1240
|
+
border: 0;
|
|
1241
|
+
}
|
|
963
1242
|
</style>
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
import { marked } from "marked";
|
|
3
3
|
import { onMount, tick } from "svelte";
|
|
4
4
|
import { sanitizeMarkdown } from "../../utils/sanitize";
|
|
5
|
+
import { extractHeaders } from "../../utils/markdown";
|
|
5
6
|
import "../../styles/content.css";
|
|
6
7
|
import { Button, Input, Logo } from '../../ui';
|
|
7
8
|
import Dialog from "../../ui/components/ui/Dialog.svelte";
|
|
8
9
|
import FloatingToolbar from "./FloatingToolbar.svelte";
|
|
10
|
+
import ContentWithGutter from "../custom/ContentWithGutter.svelte";
|
|
9
11
|
import { Eye, EyeOff, Maximize2, PenLine, Columns2, BookOpen, Focus, Minimize2 } from "lucide-svelte";
|
|
10
12
|
import { browser } from "$app/environment";
|
|
11
13
|
|
|
@@ -23,6 +25,17 @@
|
|
|
23
25
|
* @property {number} [wordCount]
|
|
24
26
|
*/
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} GutterItemProp
|
|
30
|
+
* @property {string} type
|
|
31
|
+
* @property {string} [anchor]
|
|
32
|
+
* @property {string} [content]
|
|
33
|
+
* @property {string} [url]
|
|
34
|
+
* @property {string} [file]
|
|
35
|
+
* @property {string} [caption]
|
|
36
|
+
* @property {Array<{url: string, alt?: string, caption?: string}>} [images]
|
|
37
|
+
*/
|
|
38
|
+
|
|
26
39
|
// Props
|
|
27
40
|
let {
|
|
28
41
|
content = $bindable(""),
|
|
@@ -34,6 +47,7 @@
|
|
|
34
47
|
previewTitle = "",
|
|
35
48
|
previewDate = "",
|
|
36
49
|
previewTags = /** @type {string[]} */ ([]),
|
|
50
|
+
gutterItems = /** @type {GutterItemProp[]} */ ([]),
|
|
37
51
|
} = $props();
|
|
38
52
|
|
|
39
53
|
// Core refs and state
|
|
@@ -93,6 +107,7 @@
|
|
|
93
107
|
let charCount = $derived(content.length);
|
|
94
108
|
let lineCount = $derived(content.split("\n").length);
|
|
95
109
|
let previewHtml = $derived(content ? sanitizeMarkdown(/** @type {string} */ (marked.parse(content, { async: false }))) : "");
|
|
110
|
+
let previewHeaders = $derived(content ? extractHeaders(content) : []);
|
|
96
111
|
|
|
97
112
|
let readingTime = $derived.by(() => {
|
|
98
113
|
const minutes = Math.ceil(wordCount / 200);
|
|
@@ -743,9 +758,9 @@
|
|
|
743
758
|
{#if showFullPreview}
|
|
744
759
|
<div class="full-preview-modal" role="dialog" aria-modal="true" aria-label="Full article preview" tabindex="-1" onkeydown={(e) => e.key === 'Escape' && (showFullPreview = false)}>
|
|
745
760
|
<button type="button" class="full-preview-backdrop" onclick={() => (showFullPreview = false)} aria-label="Close preview"></button>
|
|
746
|
-
<div class="full-preview-container">
|
|
761
|
+
<div class="full-preview-container" class:has-vines={gutterItems.length > 0}>
|
|
747
762
|
<header class="full-preview-header">
|
|
748
|
-
<h2>:: full preview</h2>
|
|
763
|
+
<h2>:: full preview {#if gutterItems.length > 0}<span class="vine-count">({gutterItems.length} vine{gutterItems.length !== 1 ? 's' : ''})</span>{/if}</h2>
|
|
749
764
|
<div class="full-preview-actions">
|
|
750
765
|
<button type="button" class="full-preview-close" onclick={() => (showFullPreview = false)}>
|
|
751
766
|
[<span class="key">c</span>lose]
|
|
@@ -753,45 +768,84 @@
|
|
|
753
768
|
</div>
|
|
754
769
|
</header>
|
|
755
770
|
<div class="full-preview-scroll">
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
{:else}
|
|
791
|
-
<p class="preview-placeholder">Start writing to see your content here...</p>
|
|
771
|
+
{#if gutterItems.length > 0}
|
|
772
|
+
<!-- Use ContentWithGutter when we have vines -->
|
|
773
|
+
<ContentWithGutter
|
|
774
|
+
content={previewHtml}
|
|
775
|
+
gutterContent={gutterItems}
|
|
776
|
+
headers={previewHeaders}
|
|
777
|
+
showTableOfContents={previewHeaders.length > 0}
|
|
778
|
+
>
|
|
779
|
+
{#if previewTitle || previewDate || previewTags.length > 0}
|
|
780
|
+
<header class="content-header">
|
|
781
|
+
{#if previewTitle}
|
|
782
|
+
<h1 class="full-preview-title">{previewTitle}</h1>
|
|
783
|
+
{/if}
|
|
784
|
+
{#if previewDate || previewTags.length > 0}
|
|
785
|
+
<div class="post-meta">
|
|
786
|
+
{#if previewDate}
|
|
787
|
+
<time datetime={previewDate}>
|
|
788
|
+
{new Date(previewDate).toLocaleDateString("en-US", {
|
|
789
|
+
year: "numeric",
|
|
790
|
+
month: "long",
|
|
791
|
+
day: "numeric",
|
|
792
|
+
})}
|
|
793
|
+
</time>
|
|
794
|
+
{/if}
|
|
795
|
+
{#if previewTags.length > 0}
|
|
796
|
+
<div class="tags">
|
|
797
|
+
{#each previewTags as tag}
|
|
798
|
+
<span class="tag">{tag}</span>
|
|
799
|
+
{/each}
|
|
800
|
+
</div>
|
|
801
|
+
{/if}
|
|
802
|
+
</div>
|
|
803
|
+
{/if}
|
|
804
|
+
</header>
|
|
792
805
|
{/if}
|
|
793
|
-
</
|
|
794
|
-
|
|
806
|
+
</ContentWithGutter>
|
|
807
|
+
{:else}
|
|
808
|
+
<!-- Simple preview without vines -->
|
|
809
|
+
<article class="full-preview-article">
|
|
810
|
+
{#if previewTitle || previewDate || previewTags.length > 0}
|
|
811
|
+
<header class="content-header">
|
|
812
|
+
{#if previewTitle}
|
|
813
|
+
<h1>{previewTitle}</h1>
|
|
814
|
+
{/if}
|
|
815
|
+
{#if previewDate || previewTags.length > 0}
|
|
816
|
+
<div class="post-meta">
|
|
817
|
+
{#if previewDate}
|
|
818
|
+
<time datetime={previewDate}>
|
|
819
|
+
{new Date(previewDate).toLocaleDateString("en-US", {
|
|
820
|
+
year: "numeric",
|
|
821
|
+
month: "long",
|
|
822
|
+
day: "numeric",
|
|
823
|
+
})}
|
|
824
|
+
</time>
|
|
825
|
+
{/if}
|
|
826
|
+
{#if previewTags.length > 0}
|
|
827
|
+
<div class="tags">
|
|
828
|
+
{#each previewTags as tag}
|
|
829
|
+
<span class="tag">{tag}</span>
|
|
830
|
+
{/each}
|
|
831
|
+
</div>
|
|
832
|
+
{/if}
|
|
833
|
+
</div>
|
|
834
|
+
{/if}
|
|
835
|
+
</header>
|
|
836
|
+
{/if}
|
|
837
|
+
|
|
838
|
+
<div class="content-body">
|
|
839
|
+
{#if previewHtml}
|
|
840
|
+
{#key previewHtml}
|
|
841
|
+
<div>{@html previewHtml}</div>
|
|
842
|
+
{/key}
|
|
843
|
+
{:else}
|
|
844
|
+
<p class="preview-placeholder">Start writing to see your content here...</p>
|
|
845
|
+
{/if}
|
|
846
|
+
</div>
|
|
847
|
+
</article>
|
|
848
|
+
{/if}
|
|
795
849
|
</div>
|
|
796
850
|
</div>
|
|
797
851
|
</div>
|
|
@@ -1446,6 +1500,17 @@
|
|
|
1446
1500
|
flex-direction: column;
|
|
1447
1501
|
overflow: hidden;
|
|
1448
1502
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
|
1503
|
+
transition: max-width 0.3s ease;
|
|
1504
|
+
}
|
|
1505
|
+
/* Wider container when vines are present */
|
|
1506
|
+
.full-preview-container.has-vines {
|
|
1507
|
+
max-width: 1400px;
|
|
1508
|
+
}
|
|
1509
|
+
.vine-count {
|
|
1510
|
+
font-weight: 400;
|
|
1511
|
+
color: #7a9a7a;
|
|
1512
|
+
font-size: 0.75rem;
|
|
1513
|
+
margin-left: 0.5rem;
|
|
1449
1514
|
}
|
|
1450
1515
|
:global(.dark) .full-preview-container {
|
|
1451
1516
|
background: var(--color-bg-dark, #0d1117);
|
|
@@ -21,6 +21,7 @@ declare const MarkdownEditor: import("svelte").Component<{
|
|
|
21
21
|
previewTitle?: string;
|
|
22
22
|
previewDate?: string;
|
|
23
23
|
previewTags?: any;
|
|
24
|
+
gutterItems?: any;
|
|
24
25
|
}, {
|
|
25
26
|
getAvailableAnchors: () => string[];
|
|
26
27
|
insertAnchor: (name: string) => void;
|
|
@@ -40,4 +41,5 @@ type $$ComponentProps = {
|
|
|
40
41
|
previewTitle?: string;
|
|
41
42
|
previewDate?: string;
|
|
42
43
|
previewTags?: any;
|
|
44
|
+
gutterItems?: any;
|
|
43
45
|
};
|
|
@@ -426,11 +426,21 @@
|
|
|
426
426
|
let isPurifyReady = $state(false);
|
|
427
427
|
|
|
428
428
|
// Load DOMPurify only in browser (avoids jsdom dependency for SSR)
|
|
429
|
+
// Content is already sanitized server-side, so we mark ready immediately
|
|
430
|
+
// and re-sanitize once DOMPurify loads (defensive, usually a no-op)
|
|
429
431
|
onMount(async () => {
|
|
430
432
|
if (browser) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
+
// Mark ready immediately so we don't block rendering
|
|
434
|
+
// Content was already sanitized on the server
|
|
433
435
|
isPurifyReady = true;
|
|
436
|
+
|
|
437
|
+
// Load DOMPurify in the background for additional client-side sanitization
|
|
438
|
+
try {
|
|
439
|
+
const module = await import('dompurify');
|
|
440
|
+
DOMPurify = module.default;
|
|
441
|
+
} catch (err) {
|
|
442
|
+
console.warn('DOMPurify failed to load, using server-sanitized content:', err);
|
|
443
|
+
}
|
|
434
444
|
}
|
|
435
445
|
});
|
|
436
446
|
|