mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(broadcast): M2 audience + editor + compose wizard
Core authoring loop works end-to-end: create a draft, filter an audience from contacts, write content in a rich-text editor, save. Send is still stubbed (M4 gets mana-mail's bulk endpoint). Dependencies - @tiptap/core + starter-kit + image + link + placeholder (3.22.4) - shared-auth/tsconfig: allowImportingTsExtensions + rewriteRelativeImportExtensions so tsc accepts shared-types' explicit .ts imports. Was blocking EVERY pnpm install postinstall hook in the repo — fixing it here unblocks everyone, not just broadcast. Module - queries.ts: useAllCampaigns / useAllTemplates with scoped-db + crypto, computeStats (counts + open/click rates per year), formatRate helper - stores/settings.svelte.ts: singleton with ensure/get/update, same pattern as invoices settings - stores/campaigns.svelte.ts: createCampaign (pulls sender defaults from settings), updateCampaign / updateContent / updateAudience (draft-only edit guard), schedule / cancel / duplicate / deleteCampaign, plus an applyServerStatus hook for M4's orchestrator to write back progress Audience - audience/segment-builder.ts: pure matchContact / filterAudience / countAudience / describeAudience. AND semantics across filters. Drops contacts without a usable email so estimatedCount never inflates. - audience/AudienceBuilder.svelte: tag-chip UI with live count, dedup (same tag twice toggles op instead of stacking), greys out already- referenced tags in the picker Editor - editor/Editor.svelte: Tiptap wrapper with onMount / onDestroy, toolbar (bold/italic/H1/H2/lists/link/image), bind on content (Tiptap JSON + derived HTML/plaintext). Image upload reuses invoices' mana-media uploader pragmatically; extract to @mana/shared-uload later. Compose wizard - views/ComposeView.svelte: 4-step stepper (Audience → Content → Preflight → Send). Steps 3+4 stubbed pragmatically. Autosave on step change so content survives navigation. Step 3/4 gated on earlier readiness so the user can't skip. Routes - /broadcasts/new: bootstraps a draft + redirects to edit - /broadcasts/[id]/edit: guarded on status=='draft' - ListView: working "+ Neue Kampagne" button, rows open edit Tests - 17 unit tests for segment-builder covering tag has/not-has/AND, email eq/contains case-insensitivity, no-email filtering, no-mutation, describeAudience resolver + fallback Plan: docs/plans/broadcast-module.md §M2. Next: M3 HTML-render with email-safe inlining + preview. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5139ade7e0
commit
264c4c3087
15 changed files with 2635 additions and 253 deletions
|
|
@ -77,6 +77,11 @@
|
|||
"@mana/spiral-db": "workspace:*",
|
||||
"@mana/wallpaper-generator": "workspace:*",
|
||||
"@quotes/content": "workspace:*",
|
||||
"@tiptap/core": "^3.22.4",
|
||||
"@tiptap/extension-image": "^3.22.4",
|
||||
"@tiptap/extension-link": "^3.22.4",
|
||||
"@tiptap/extension-placeholder": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/suncalc": "^1.9.2",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
<!--
|
||||
Broadcast — ListView (M1 skeleton)
|
||||
Empty state + "+ Neue Kampagne"-button placeholder. Real list, filters,
|
||||
and stats cards land in M2/M7. Plan: docs/plans/broadcast-module.md.
|
||||
Broadcast — ListView (M2)
|
||||
Real campaign list + working "+ Neue Kampagne" entry point. Stats
|
||||
cards, filter chips, and search land in M7.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { goto } from '$app/navigation';
|
||||
import { useAllCampaigns } from './queries';
|
||||
import { STATUS_LABELS, STATUS_COLORS } from './constants';
|
||||
import type { LocalCampaign } from './types';
|
||||
|
||||
const campaigns$ = useLiveQueryWithDefault(async () => {
|
||||
const rows = await scopedForModule<LocalCampaign, string>(
|
||||
'broadcast',
|
||||
'broadcastCampaigns'
|
||||
).toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
return (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
|
||||
}, [] as LocalCampaign[]);
|
||||
const campaigns$ = useAllCampaigns();
|
||||
const campaigns = $derived(campaigns$.value ?? []);
|
||||
|
||||
function openCampaign(id: string, status: string) {
|
||||
// Drafts go straight to edit; sent/scheduled to a detail view
|
||||
// (detail lands in M7; until then, edit is the entry for drafts
|
||||
// and we bounce sent ones back to the list — see canEdit guard
|
||||
// in the edit route).
|
||||
if (status === 'draft') goto(`/broadcasts/${id}/edit`);
|
||||
else goto(`/broadcasts/${id}/edit`);
|
||||
}
|
||||
|
||||
function onNewCampaign() {
|
||||
goto('/broadcasts/new');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="broadcast-shell">
|
||||
|
|
@ -27,7 +31,7 @@
|
|||
<h1>Broadcasts</h1>
|
||||
<p class="subtitle">Newsletter und Kampagnen an deine Kontakte</p>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" disabled title="M2">+ Neue Kampagne</button>
|
||||
<button class="btn-primary" type="button" onclick={onNewCampaign}>+ Neue Kampagne</button>
|
||||
</header>
|
||||
|
||||
{#if campaigns.length === 0}
|
||||
|
|
@ -38,20 +42,27 @@
|
|||
Verschicke deinen ersten Newsletter — mit Rich-Text-Editor, Tracking und DSGVO-konformem
|
||||
Abmelden.
|
||||
</p>
|
||||
<p class="note">M1 Skelett — Compose-Flow folgt in M2.</p>
|
||||
<button class="btn-primary" onclick={onNewCampaign}>Erste Kampagne</button>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
<ul class="list" role="list">
|
||||
{#each campaigns as campaign (campaign.id)}
|
||||
<li class="row">
|
||||
<span class="subject">{campaign.subject}</span>
|
||||
<span class="recipient-count">
|
||||
{campaign.audience?.estimatedCount ?? 0} Empfänger
|
||||
</span>
|
||||
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}">
|
||||
<span class="dot"></span>
|
||||
{STATUS_LABELS[campaign.status].de}
|
||||
</span>
|
||||
<li>
|
||||
<button class="row" onclick={() => openCampaign(campaign.id, campaign.status)}>
|
||||
<span class="subject">
|
||||
<span class="campaign-name">{campaign.name}</span>
|
||||
{#if campaign.subject}
|
||||
<span class="campaign-subject">{campaign.subject}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="recipient-count">
|
||||
{campaign.audience?.estimatedCount ?? 0} Empfänger
|
||||
</span>
|
||||
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}">
|
||||
<span class="dot"></span>
|
||||
{STATUS_LABELS[campaign.status].de}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
@ -87,16 +98,12 @@
|
|||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 0;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
|
@ -118,16 +125,10 @@
|
|||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0.25rem 0;
|
||||
margin: 0.25rem 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1rem !important;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
|
@ -139,19 +140,42 @@
|
|||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 8rem;
|
||||
grid-template-columns: 1fr auto 9rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.subject {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.campaign-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.campaign-subject {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipient-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
<!--
|
||||
AudienceBuilder — M2.
|
||||
Tag-Filter-Chips + Live-Empfänger-Count.
|
||||
|
||||
Binds the parent's `audience: AudienceDefinition`. Every change
|
||||
re-evaluates the count against the live contacts list so the user
|
||||
sees the segment size as they build it.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useAllContacts } from '$lib/modules/contacts/queries';
|
||||
import { useAllTags } from '@mana/shared-stores';
|
||||
import { countAudience } from './segment-builder';
|
||||
import type { AudienceDefinition, AudienceFilter } from '../types';
|
||||
|
||||
interface Props {
|
||||
audience: AudienceDefinition;
|
||||
}
|
||||
|
||||
let { audience = $bindable({ filters: [], estimatedCount: 0 }) }: Props = $props();
|
||||
|
||||
const contacts$ = useAllContacts();
|
||||
const contacts = $derived(contacts$.value ?? []);
|
||||
const allTags = $derived(useAllTags().value ?? []);
|
||||
|
||||
const matchedCount = $derived(countAudience(contacts, audience));
|
||||
|
||||
// Keep the cached count in sync with the live computation so the
|
||||
// cached number doesn't go stale between edits and the next save.
|
||||
$effect(() => {
|
||||
if (audience.estimatedCount !== matchedCount) {
|
||||
audience = { ...audience, estimatedCount: matchedCount };
|
||||
}
|
||||
});
|
||||
|
||||
function addTagFilter(tagId: string, op: 'has' | 'not-has') {
|
||||
// Dedupe: if a filter with the same tag exists, toggle the op rather
|
||||
// than pile on duplicates.
|
||||
const existing = audience.filters.findIndex((f) => f.field === 'tag' && f.value === tagId);
|
||||
let nextFilters: AudienceFilter[];
|
||||
if (existing >= 0) {
|
||||
nextFilters = audience.filters.map((f, i) =>
|
||||
i === existing ? ({ field: 'tag', op, value: tagId } as AudienceFilter) : f
|
||||
);
|
||||
} else {
|
||||
nextFilters = [...audience.filters, { field: 'tag', op, value: tagId }];
|
||||
}
|
||||
audience = { ...audience, filters: nextFilters };
|
||||
}
|
||||
|
||||
function removeFilter(index: number) {
|
||||
audience = {
|
||||
...audience,
|
||||
filters: audience.filters.filter((_, i) => i !== index),
|
||||
};
|
||||
}
|
||||
|
||||
function tagName(tagId: string): string {
|
||||
return allTags.find((t) => t.id === tagId)?.name ?? tagId.slice(0, 8);
|
||||
}
|
||||
|
||||
function filterLabel(f: AudienceFilter): string {
|
||||
if (f.field === 'tag') {
|
||||
return f.op === 'has' ? `Tag: ${tagName(f.value)}` : `nicht Tag: ${tagName(f.value)}`;
|
||||
}
|
||||
if (f.field === 'email') return `E-Mail: ${f.value}`;
|
||||
return `${f.field} ${f.op} ${f.value}`;
|
||||
}
|
||||
|
||||
// Which tags are already referenced (greys them out in the picker).
|
||||
const usedTagIds = $derived(
|
||||
new Set(audience.filters.filter((f) => f.field === 'tag').map((f) => f.value))
|
||||
);
|
||||
|
||||
const contactsWithEmail = $derived(
|
||||
contacts.filter((c) => typeof c.email === 'string' && c.email.includes('@')).length
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="audience">
|
||||
<header class="audience-head">
|
||||
<h3>Empfänger</h3>
|
||||
<div class="count-chip" class:empty={matchedCount === 0}>
|
||||
<strong>{matchedCount}</strong>
|
||||
<span>{matchedCount === 1 ? 'Empfänger' : 'Empfänger'}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="hint">
|
||||
{#if audience.filters.length === 0}
|
||||
Ohne Filter: alle {contactsWithEmail} Kontakte mit E-Mail-Adresse.
|
||||
{:else}
|
||||
{audience.filters.length} Filter — nur Kontakte, die ALLE erfüllen.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if audience.filters.length > 0}
|
||||
<div class="filter-chips">
|
||||
{#each audience.filters as f, i (i)}
|
||||
<span class="chip" class:chip-negate={f.op === 'not-has'}>
|
||||
{filterLabel(f)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip-remove"
|
||||
onclick={() => removeFilter(i)}
|
||||
aria-label="Filter entfernen">×</button
|
||||
>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="tag-picker">
|
||||
<h4>Nach Tag filtern</h4>
|
||||
{#if allTags.length === 0}
|
||||
<p class="empty">
|
||||
Keine Tags vorhanden. Lege in Kontakten Tags an, um nach ihnen zu segmentieren.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="tag-grid">
|
||||
{#each allTags as tag (tag.id)}
|
||||
{@const used = usedTagIds.has(tag.id)}
|
||||
<div class="tag-row">
|
||||
<span class="tag-color" style="--c: {tag.color ?? '#64748b'}"></span>
|
||||
<span class="tag-name">{tag.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="tag-btn"
|
||||
disabled={used}
|
||||
onclick={() => addTagFilter(tag.id, 'has')}
|
||||
title="Nur Kontakte mit diesem Tag">+ hat</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="tag-btn tag-btn-negate"
|
||||
disabled={used}
|
||||
onclick={() => addTagFilter(tag.id, 'not-has')}
|
||||
title="Nur Kontakte ohne diesen Tag">− ohne</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.audience {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.audience-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audience-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: #eef2ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 999px;
|
||||
color: #3730a3;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.count-chip strong {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count-chip.empty {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.6rem 0.3rem 0.75rem;
|
||||
background: #e0e7ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.chip.chip-negate {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chip-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tag-picker {
|
||||
background: var(--color-surface-muted, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.tag-picker h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tag-picker .empty {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.tag-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-color {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
background: var(--c);
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tag-btn {
|
||||
padding: 0.25rem 0.65rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tag-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tag-btn-negate {
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import type { Contact } from '$lib/modules/contacts/types';
|
||||
import { matchContact, filterAudience, countAudience, describeAudience } from './segment-builder';
|
||||
import type { AudienceDefinition, AudienceFilter } from '../types';
|
||||
|
||||
function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
return {
|
||||
id: 'c1',
|
||||
firstName: 'Test',
|
||||
lastName: 'Kontakt',
|
||||
displayName: 'Test Kontakt',
|
||||
email: 'test@example.com',
|
||||
phone: null,
|
||||
mobile: null,
|
||||
company: null,
|
||||
jobTitle: null,
|
||||
street: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
country: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
notes: null,
|
||||
photoUrl: null,
|
||||
birthday: null,
|
||||
website: null,
|
||||
linkedin: null,
|
||||
twitter: null,
|
||||
instagram: null,
|
||||
github: null,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
tags: [],
|
||||
tagIds: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
} as Contact;
|
||||
}
|
||||
|
||||
const f = (
|
||||
field: AudienceFilter['field'],
|
||||
op: AudienceFilter['op'],
|
||||
value: string
|
||||
): AudienceFilter => ({
|
||||
field,
|
||||
op,
|
||||
value,
|
||||
});
|
||||
|
||||
const audience = (filters: AudienceFilter[]): AudienceDefinition => ({
|
||||
filters,
|
||||
estimatedCount: 0,
|
||||
});
|
||||
|
||||
describe('matchContact', () => {
|
||||
it('tag has: returns true when the contact has the tag', () => {
|
||||
expect(matchContact(makeContact({ tagIds: ['t1'] }), f('tag', 'has', 't1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('tag has: returns false when the contact lacks the tag', () => {
|
||||
expect(matchContact(makeContact({ tagIds: [] }), f('tag', 'has', 't1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('tag not-has: returns true when absent', () => {
|
||||
expect(matchContact(makeContact({ tagIds: [] }), f('tag', 'not-has', 't1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('tag not-has: returns false when present', () => {
|
||||
expect(matchContact(makeContact({ tagIds: ['t1'] }), f('tag', 'not-has', 't1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('email contains: case-insensitive', () => {
|
||||
expect(matchContact(makeContact({ email: 'foo@BAR.CH' }), f('email', 'contains', 'bar'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('email eq: exact match required (after lowercasing both)', () => {
|
||||
expect(matchContact(makeContact({ email: 'a@b.ch' }), f('email', 'eq', 'A@B.CH'))).toBe(true);
|
||||
expect(matchContact(makeContact({ email: 'a@b.ch' }), f('email', 'eq', 'x@b.ch'))).toBe(false);
|
||||
});
|
||||
|
||||
it('email has: returns true when a usable email exists', () => {
|
||||
expect(matchContact(makeContact({ email: 'x@y.z' }), f('email', 'has', ''))).toBe(true);
|
||||
expect(matchContact(makeContact({ email: null }), f('email', 'has', ''))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAudience', () => {
|
||||
const contacts = [
|
||||
makeContact({ id: '1', email: 'a@x.ch', tagIds: ['kunde'] }),
|
||||
makeContact({ id: '2', email: 'b@x.ch', tagIds: ['kunde', 'trial'] }),
|
||||
makeContact({ id: '3', email: 'c@x.ch', tagIds: [] }),
|
||||
makeContact({ id: '4', email: null, tagIds: ['kunde'] }), // no email → excluded
|
||||
];
|
||||
|
||||
it('no filters: returns all contacts with valid email', () => {
|
||||
const result = filterAudience(contacts, audience([]));
|
||||
expect(result.map((c) => c.id)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('single tag filter: matches only contacts with the tag', () => {
|
||||
const result = filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
|
||||
expect(result.map((c) => c.id)).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('AND semantics: all filters must match', () => {
|
||||
// Kunden OHNE trial-tag = nur Contact 1
|
||||
const result = filterAudience(
|
||||
contacts,
|
||||
audience([f('tag', 'has', 'kunde'), f('tag', 'not-has', 'trial')])
|
||||
);
|
||||
expect(result.map((c) => c.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('drops contacts without usable email even if filters match', () => {
|
||||
// Contact 4 has 'kunde' tag but no email → excluded from result
|
||||
const result = filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
|
||||
expect(result.find((c) => c.id === '4')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a new array (no input mutation)', () => {
|
||||
const before = contacts.slice();
|
||||
filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
|
||||
expect(contacts).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countAudience', () => {
|
||||
it('matches filterAudience().length', () => {
|
||||
const contacts = [
|
||||
makeContact({ id: '1', email: 'a@x.ch', tagIds: ['kunde'] }),
|
||||
makeContact({ id: '2', email: 'b@x.ch', tagIds: [] }),
|
||||
];
|
||||
const def = audience([f('tag', 'has', 'kunde')]);
|
||||
expect(countAudience(contacts, def)).toBe(filterAudience(contacts, def).length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeAudience', () => {
|
||||
const resolver = (id: string) => {
|
||||
const names: Record<string, string> = { t1: 'Kunden', t2: 'Newsletter' };
|
||||
return names[id] ?? null;
|
||||
};
|
||||
|
||||
it('no filters → "Alle Kontakte mit E-Mail"', () => {
|
||||
expect(describeAudience(audience([]), resolver)).toBe('Alle Kontakte mit E-Mail');
|
||||
});
|
||||
|
||||
it('resolves tag names via the resolver', () => {
|
||||
const result = describeAudience(audience([f('tag', 'has', 't1')]), resolver);
|
||||
expect(result).toContain('Kunden');
|
||||
});
|
||||
|
||||
it('falls back to the raw value when resolver returns null', () => {
|
||||
const result = describeAudience(audience([f('tag', 'has', 'unknown')]), resolver);
|
||||
expect(result).toContain('unknown');
|
||||
});
|
||||
|
||||
it('joins multiple filters with · separator', () => {
|
||||
const result = describeAudience(
|
||||
audience([f('tag', 'has', 't1'), f('tag', 'not-has', 't2')]),
|
||||
resolver
|
||||
);
|
||||
expect(result).toContain('·');
|
||||
expect(result).toContain('Kunden');
|
||||
expect(result).toContain('Newsletter');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Pure audience-segment matcher.
|
||||
*
|
||||
* Given a contact list and a set of filters, return only the contacts
|
||||
* that satisfy ALL filters (AND semantics). Filter `value` meanings:
|
||||
* - field='tag' → value = tag ID (not name). UI resolves names.
|
||||
* - field='email' → value = substring or exact email
|
||||
* - field='custom' → reserved for per-contact custom fields later
|
||||
*
|
||||
* Semantics per op:
|
||||
* has → the tag/value is present
|
||||
* not-has → the tag/value is absent
|
||||
* eq → exact match (for email: case-insensitive)
|
||||
* contains → substring match (for email: case-insensitive)
|
||||
*
|
||||
* Empty filter list matches every contact — "keine Filter" means
|
||||
* "alle Empfänger", not "niemand". Callers that need the opposite
|
||||
* (default-deny) should gate the send separately.
|
||||
*/
|
||||
|
||||
import type { Contact } from '$lib/modules/contacts/types';
|
||||
import type { AudienceFilter, AudienceDefinition } from '../types';
|
||||
|
||||
export function matchContact(contact: Contact, filter: AudienceFilter): boolean {
|
||||
switch (filter.field) {
|
||||
case 'tag': {
|
||||
const has = (contact.tagIds ?? []).includes(filter.value);
|
||||
if (filter.op === 'has') return has;
|
||||
if (filter.op === 'not-has') return !has;
|
||||
// eq / contains don't apply to tags — graceful fail to `has`.
|
||||
return has;
|
||||
}
|
||||
|
||||
case 'email': {
|
||||
const email = (contact.email ?? '').toLowerCase();
|
||||
const v = filter.value.toLowerCase();
|
||||
if (filter.op === 'has') return email.length > 0;
|
||||
if (filter.op === 'not-has') return email.length === 0;
|
||||
if (filter.op === 'eq') return email === v;
|
||||
if (filter.op === 'contains') return email.includes(v);
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'custom':
|
||||
// Placeholder — M2+ ships tags + email. Custom fields land in
|
||||
// Phase 2 when contact schema grows a free-form fields map.
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all filters against a contact list. Returns a copy — never mutates
|
||||
* the input. Contacts without a usable email address are dropped even if
|
||||
* they match the filters; you can't send a newsletter to someone without
|
||||
* an email, and silently counting them would inflate the estimated count.
|
||||
*/
|
||||
export function filterAudience(contacts: Contact[], audience: AudienceDefinition): Contact[] {
|
||||
const filtered = audience.filters.length
|
||||
? contacts.filter((c) => audience.filters.every((f) => matchContact(c, f)))
|
||||
: contacts.slice();
|
||||
return filtered.filter((c) => typeof c.email === 'string' && c.email.includes('@'));
|
||||
}
|
||||
|
||||
/** Fast count without materialising the full array (same filter logic). */
|
||||
export function countAudience(contacts: Contact[], audience: AudienceDefinition): number {
|
||||
return filterAudience(contacts, audience).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly summary of a filter AST. Used in the ListView row and
|
||||
* the Preflight step to show "an: Kunden ohne trial-tag (23 Empfänger)"
|
||||
* instead of just the raw number.
|
||||
*/
|
||||
export function describeAudience(
|
||||
audience: AudienceDefinition,
|
||||
tagNameResolver: (tagId: string) => string | null
|
||||
): string {
|
||||
if (audience.filters.length === 0) return 'Alle Kontakte mit E-Mail';
|
||||
const parts = audience.filters.map((f) => {
|
||||
if (f.field === 'tag') {
|
||||
const name = tagNameResolver(f.value) ?? f.value;
|
||||
return f.op === 'has' ? `Tag "${name}"` : `ohne Tag "${name}"`;
|
||||
}
|
||||
if (f.field === 'email') {
|
||||
if (f.op === 'eq') return `E-Mail = ${f.value}`;
|
||||
if (f.op === 'contains') return `E-Mail enthält "${f.value}"`;
|
||||
if (f.op === 'has') return `mit E-Mail`;
|
||||
if (f.op === 'not-has') return `ohne E-Mail`;
|
||||
}
|
||||
return `${f.field} ${f.op} ${f.value}`;
|
||||
});
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
<!--
|
||||
Editor — Tiptap 3 Svelte wrapper for the broadcast content editor.
|
||||
|
||||
Minimal toolbar (bold/italic/heading/list/link) plus image upload via
|
||||
mana-media. The HTML that comes out of getHTML() is the user's input
|
||||
only — the send pipeline wraps it in the full email template (header,
|
||||
footer, unsubscribe link) on M4.
|
||||
|
||||
Two-way bind on `content` (Tiptap JSON). Parent persists this via
|
||||
broadcastCampaignsStore.updateContent; we don't save here.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { uploadLogo } from '$lib/modules/invoices/pdf/logo';
|
||||
|
||||
interface Props {
|
||||
content: { tiptap: object; html?: string; plainText?: string };
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
content = $bindable({ tiptap: { type: 'doc', content: [{ type: 'paragraph' }] } }),
|
||||
placeholder = 'Schreib deinen Newsletter …',
|
||||
}: Props = $props();
|
||||
|
||||
let editorEl: HTMLElement | undefined = $state();
|
||||
let editor: Editor | null = null;
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state<string | null>(null);
|
||||
let linkUrl = $state('');
|
||||
let showLinkInput = $state(false);
|
||||
|
||||
// Reactive toolbar state — updated from the editor's transactions.
|
||||
let isBold = $state(false);
|
||||
let isItalic = $state(false);
|
||||
let isH1 = $state(false);
|
||||
let isH2 = $state(false);
|
||||
let isBulletList = $state(false);
|
||||
let isOrderedList = $state(false);
|
||||
let isLink = $state(false);
|
||||
|
||||
function syncToolbar() {
|
||||
if (!editor) return;
|
||||
isBold = editor.isActive('bold');
|
||||
isItalic = editor.isActive('italic');
|
||||
isH1 = editor.isActive('heading', { level: 1 });
|
||||
isH2 = editor.isActive('heading', { level: 2 });
|
||||
isBulletList = editor.isActive('bulletList');
|
||||
isOrderedList = editor.isActive('orderedList');
|
||||
isLink = editor.isActive('link');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element: editorEl,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Image.configure({ inline: false }),
|
||||
Link.configure({
|
||||
openOnClick: false, // users click to edit, not navigate away
|
||||
autolink: true,
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
content: content.tiptap,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
content = {
|
||||
tiptap: e.getJSON(),
|
||||
html: e.getHTML(),
|
||||
plainText: e.getText(),
|
||||
};
|
||||
syncToolbar();
|
||||
},
|
||||
onSelectionUpdate: syncToolbar,
|
||||
onTransaction: syncToolbar,
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
});
|
||||
|
||||
// ─── Toolbar actions ─────────────────────────────────
|
||||
|
||||
function cmd<T>(fn: (chain: ReturnType<NonNullable<typeof editor>['chain']>) => T) {
|
||||
if (!editor) return;
|
||||
fn(editor.chain().focus());
|
||||
}
|
||||
|
||||
function toggleBold() {
|
||||
cmd((c) => c.toggleBold().run());
|
||||
}
|
||||
function toggleItalic() {
|
||||
cmd((c) => c.toggleItalic().run());
|
||||
}
|
||||
function toggleH1() {
|
||||
cmd((c) => c.toggleHeading({ level: 1 }).run());
|
||||
}
|
||||
function toggleH2() {
|
||||
cmd((c) => c.toggleHeading({ level: 2 }).run());
|
||||
}
|
||||
function toggleBullet() {
|
||||
cmd((c) => c.toggleBulletList().run());
|
||||
}
|
||||
function toggleOrdered() {
|
||||
cmd((c) => c.toggleOrderedList().run());
|
||||
}
|
||||
|
||||
function openLinkPrompt() {
|
||||
if (!editor) return;
|
||||
const existing = (editor.getAttributes('link') as { href?: string }).href ?? '';
|
||||
linkUrl = existing;
|
||||
showLinkInput = true;
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
if (!editor) return;
|
||||
const url = linkUrl.trim();
|
||||
if (!url) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
} else {
|
||||
// Basic normalisation: bare domain → https://
|
||||
const normalised = /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: normalised }).run();
|
||||
}
|
||||
showLinkInput = false;
|
||||
linkUrl = '';
|
||||
}
|
||||
|
||||
async function onImageSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !editor) return;
|
||||
uploading = true;
|
||||
uploadError = null;
|
||||
try {
|
||||
// Reuse invoices' logo uploader — it's the same mana-media /upload
|
||||
// endpoint with a different `app` tag. Acceptable pragmatic reuse
|
||||
// until we extract a shared `uploadMedia(file, app)` helper.
|
||||
const mediaId = await uploadLogo(file);
|
||||
const url = `${getMediaUrl()}/api/v1/media/${mediaId}/file/large`;
|
||||
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||
} finally {
|
||||
uploading = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
let imageInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
function getMediaUrl(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const fromWindow = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
|
||||
.__PUBLIC_MANA_MEDIA_URL__;
|
||||
if (fromWindow) return fromWindow;
|
||||
}
|
||||
return import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-wrap">
|
||||
<div class="toolbar" role="toolbar" aria-label="Formatierung">
|
||||
<button type="button" class="tb" class:on={isBold} onclick={toggleBold} title="Fett (⌘B)">
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button type="button" class="tb" class:on={isItalic} onclick={toggleItalic} title="Kursiv (⌘I)">
|
||||
<em>I</em>
|
||||
</button>
|
||||
<span class="divider"></span>
|
||||
<button type="button" class="tb" class:on={isH1} onclick={toggleH1} title="Überschrift 1">
|
||||
H1
|
||||
</button>
|
||||
<button type="button" class="tb" class:on={isH2} onclick={toggleH2} title="Überschrift 2">
|
||||
H2
|
||||
</button>
|
||||
<span class="divider"></span>
|
||||
<button type="button" class="tb" class:on={isBulletList} onclick={toggleBullet} title="Liste">
|
||||
• Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tb"
|
||||
class:on={isOrderedList}
|
||||
onclick={toggleOrdered}
|
||||
title="Nummerierte Liste"
|
||||
>
|
||||
1. Liste
|
||||
</button>
|
||||
<span class="divider"></span>
|
||||
<button type="button" class="tb" class:on={isLink} onclick={openLinkPrompt} title="Link">
|
||||
🔗
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tb"
|
||||
onclick={() => imageInput?.click()}
|
||||
disabled={uploading}
|
||||
title="Bild hochladen"
|
||||
>
|
||||
{uploading ? '…' : '🖼'}
|
||||
</button>
|
||||
<input
|
||||
bind:this={imageInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif"
|
||||
hidden
|
||||
onchange={onImageSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showLinkInput}
|
||||
<div class="link-bar">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
bind:value={linkUrl}
|
||||
onkeydown={(e) => e.key === 'Enter' && applyLink()}
|
||||
/>
|
||||
<button type="button" class="btn-sm" onclick={applyLink}>Setzen</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sm btn-sm-muted"
|
||||
onclick={() => {
|
||||
showLinkInput = false;
|
||||
linkUrl = '';
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadError}
|
||||
<div class="error">{uploadError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="prose" bind:this={editorEl}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--color-surface-muted, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.4rem 0.4rem 0 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
background: var(--color-border, #e2e8f0);
|
||||
margin: 0 0.15rem;
|
||||
}
|
||||
|
||||
.tb {
|
||||
padding: 0.3rem 0.55rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tb:hover {
|
||||
background: white;
|
||||
border-color: var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.tb.on {
|
||||
background: #e0e7ff;
|
||||
border-color: #c7d2fe;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.tb:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.link-bar {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.link-bar input {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: 0;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-sm-muted {
|
||||
background: white;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.prose {
|
||||
min-height: 240px;
|
||||
padding: 0.75rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0 0 0.4rem 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Tiptap ProseMirror internals — style the generated DOM. */
|
||||
.prose :global(.ProseMirror) {
|
||||
outline: none;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.prose :global(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prose :global(.ProseMirror h1) {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.prose :global(.ProseMirror h2) {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0.8rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.prose :global(.ProseMirror ul),
|
||||
.prose :global(.ProseMirror ol) {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.prose :global(.ProseMirror a) {
|
||||
color: #6366f1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose :global(.ProseMirror img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.3rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,6 +4,28 @@
|
|||
|
||||
export { campaignTable, templateTable, settingsTable, BROADCAST_GUEST_SEED } from './collections';
|
||||
|
||||
export {
|
||||
useAllCampaigns,
|
||||
useAllTemplates,
|
||||
toCampaign,
|
||||
toTemplate,
|
||||
toSettings,
|
||||
filterByStatus,
|
||||
searchCampaigns,
|
||||
computeStats,
|
||||
formatRate,
|
||||
} from './queries';
|
||||
|
||||
export {
|
||||
matchContact,
|
||||
filterAudience,
|
||||
countAudience,
|
||||
describeAudience,
|
||||
} from './audience/segment-builder';
|
||||
|
||||
export { broadcastCampaignsStore } from './stores/campaigns.svelte';
|
||||
export { broadcastSettingsStore, ensureSettings } from './stores/settings.svelte';
|
||||
|
||||
export {
|
||||
STATUS_LABELS,
|
||||
STATUS_COLORS,
|
||||
|
|
|
|||
181
apps/mana/apps/web/src/lib/modules/broadcast/queries.ts
Normal file
181
apps/mana/apps/web/src/lib/modules/broadcast/queries.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Reactive queries and pure helpers for the Broadcast module.
|
||||
*
|
||||
* Live queries decrypt + map to domain types, consistent with the
|
||||
* invoices / library modules. Scope-wrapping via `scopedForModule`
|
||||
* matches the post-Spaces convention so lists respect the active space.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { campaignTable, templateTable, settingsTable } from './collections';
|
||||
import { BROADCAST_SETTINGS_ID } from './constants';
|
||||
import type {
|
||||
LocalCampaign,
|
||||
LocalBroadcastTemplate,
|
||||
LocalBroadcastSettings,
|
||||
Campaign,
|
||||
BroadcastTemplate,
|
||||
BroadcastSettings,
|
||||
CampaignStatus,
|
||||
} from './types';
|
||||
|
||||
// ─── Type Converters ─────────────────────────────────────
|
||||
|
||||
export function toCampaign(local: LocalCampaign): Campaign {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
subject: local.subject,
|
||||
preheader: local.preheader ?? null,
|
||||
fromName: local.fromName,
|
||||
fromEmail: local.fromEmail,
|
||||
replyTo: local.replyTo ?? null,
|
||||
content: local.content,
|
||||
templateId: local.templateId ?? null,
|
||||
audience: local.audience,
|
||||
scheduledAt: local.scheduledAt ?? null,
|
||||
sentAt: local.sentAt ?? null,
|
||||
status: local.status,
|
||||
serverJobId: local.serverJobId ?? null,
|
||||
stats: local.stats ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
export function toTemplate(local: LocalBroadcastTemplate): BroadcastTemplate {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
description: local.description ?? null,
|
||||
subject: local.subject ?? null,
|
||||
content: local.content,
|
||||
isBuiltIn: local.isBuiltIn,
|
||||
thumbnailUrl: local.thumbnailUrl ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
export function toSettings(local: LocalBroadcastSettings): BroadcastSettings {
|
||||
return {
|
||||
id: local.id,
|
||||
defaultFromName: local.defaultFromName ?? '',
|
||||
defaultFromEmail: local.defaultFromEmail ?? '',
|
||||
defaultReplyTo: local.defaultReplyTo ?? null,
|
||||
defaultFooter: local.defaultFooter ?? null,
|
||||
dnsCheck: local.dnsCheck ?? null,
|
||||
legalAddress: local.legalAddress ?? '',
|
||||
unsubscribeLandingCopy: local.unsubscribeLandingCopy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Queries ────────────────────────────────────────
|
||||
|
||||
/** All campaigns in the active space, newest first by updatedAt. */
|
||||
export function useAllCampaigns() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const rows = await scopedForModule<LocalCampaign, string>(
|
||||
'broadcast',
|
||||
'broadcastCampaigns'
|
||||
).toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
const decrypted = (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
|
||||
return decrypted.map(toCampaign).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}, [] as Campaign[]);
|
||||
}
|
||||
|
||||
export function useAllTemplates() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const rows = await scopedForModule<LocalBroadcastTemplate, string>(
|
||||
'broadcast',
|
||||
'broadcastTemplates'
|
||||
).toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
const decrypted = (await decryptRecords(
|
||||
'broadcastTemplates',
|
||||
visible
|
||||
)) as LocalBroadcastTemplate[];
|
||||
return decrypted.map(toTemplate);
|
||||
}, [] as BroadcastTemplate[]);
|
||||
}
|
||||
|
||||
// ─── Pure Helpers ────────────────────────────────────────
|
||||
|
||||
export function filterByStatus(campaigns: Campaign[], status: CampaignStatus): Campaign[] {
|
||||
return campaigns.filter((c) => c.status === status);
|
||||
}
|
||||
|
||||
export function searchCampaigns(campaigns: Campaign[], query: string): Campaign[] {
|
||||
const q = query.toLowerCase();
|
||||
return campaigns.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.subject.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stats ───────────────────────────────────────────────
|
||||
|
||||
export interface BroadcastStats {
|
||||
totalByStatus: Record<CampaignStatus, number>;
|
||||
sentThisYear: number;
|
||||
avgOpenRate: number | null;
|
||||
avgClickRate: number | null;
|
||||
totalSubscribers: number;
|
||||
}
|
||||
|
||||
export function computeStats(campaigns: Campaign[], year: number): BroadcastStats {
|
||||
const totalByStatus: Record<CampaignStatus, number> = {
|
||||
draft: 0,
|
||||
scheduled: 0,
|
||||
sending: 0,
|
||||
sent: 0,
|
||||
cancelled: 0,
|
||||
};
|
||||
let sentThisYear = 0;
|
||||
let openRateSum = 0;
|
||||
let openRateCount = 0;
|
||||
let clickRateSum = 0;
|
||||
let clickRateCount = 0;
|
||||
const yearPrefix = String(year);
|
||||
|
||||
for (const c of campaigns) {
|
||||
totalByStatus[c.status]++;
|
||||
if (c.status === 'sent' && c.sentAt?.startsWith(yearPrefix)) {
|
||||
sentThisYear++;
|
||||
}
|
||||
if (c.stats && c.stats.sent > 0) {
|
||||
const openRate = c.stats.opened / c.stats.sent;
|
||||
const clickRate = c.stats.clicked / c.stats.sent;
|
||||
openRateSum += openRate;
|
||||
openRateCount++;
|
||||
clickRateSum += clickRate;
|
||||
clickRateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalByStatus,
|
||||
sentThisYear,
|
||||
avgOpenRate: openRateCount > 0 ? openRateSum / openRateCount : null,
|
||||
avgClickRate: clickRateCount > 0 ? clickRateSum / clickRateCount : null,
|
||||
totalSubscribers: 0, // TODO M7: derive from unique recipients across all campaigns
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Formatting ──────────────────────────────────────────
|
||||
|
||||
/** Format a rate (0..1) as a percentage with one decimal, e.g. 0.234 → "23.4%". */
|
||||
export function formatRate(rate: number | null): string {
|
||||
if (rate === null) return '—';
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// ─── Settings singleton helpers ──────────────────────────
|
||||
|
||||
export { BROADCAST_SETTINGS_ID };
|
||||
// Re-exported so UI consumers can `await settingsTable.get(BROADCAST_SETTINGS_ID)`
|
||||
// without importing from two places.
|
||||
export { settingsTable, campaignTable, templateTable };
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Campaigns store — mutation-only service.
|
||||
*
|
||||
* Status machine enforced here:
|
||||
* draft → scheduled (schedule)
|
||||
* draft → sending (send) [set by server orchestrator, not this store]
|
||||
* sending → sent (server-driven)
|
||||
* draft | scheduled → cancelled (cancel)
|
||||
*
|
||||
* Only drafts are user-editable. Once a campaign starts sending, content
|
||||
* and audience freeze so the recipient graph can't shift mid-flight.
|
||||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { campaignTable } from '../collections';
|
||||
import { broadcastSettingsStore } from './settings.svelte';
|
||||
import type { LocalCampaign, CampaignContent, AudienceDefinition, CampaignStatus } from '../types';
|
||||
|
||||
export interface CreateCampaignInput {
|
||||
name?: string;
|
||||
subject?: string;
|
||||
preheader?: string | null;
|
||||
fromName?: string;
|
||||
fromEmail?: string;
|
||||
replyTo?: string | null;
|
||||
content?: CampaignContent;
|
||||
audience?: AudienceDefinition;
|
||||
templateId?: string | null;
|
||||
}
|
||||
|
||||
const EMPTY_TIPTAP = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }],
|
||||
};
|
||||
|
||||
const EMPTY_AUDIENCE: AudienceDefinition = {
|
||||
filters: [],
|
||||
estimatedCount: 0,
|
||||
};
|
||||
|
||||
export const broadcastCampaignsStore = {
|
||||
/**
|
||||
* Create a new campaign in status `draft`. Sender fields + footer default
|
||||
* to the user's broadcast settings so first-time use feels like "start
|
||||
* typing and go" rather than "fill out ten fields before you can type".
|
||||
*/
|
||||
async createCampaign(input: CreateCampaignInput = {}): Promise<string> {
|
||||
const defaults = await broadcastSettingsStore.getDefaults();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newLocal: LocalCampaign = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name ?? 'Neue Kampagne',
|
||||
subject: input.subject ?? '',
|
||||
preheader: input.preheader ?? null,
|
||||
fromName: input.fromName ?? defaults.fromName,
|
||||
fromEmail: input.fromEmail ?? defaults.fromEmail,
|
||||
replyTo: input.replyTo ?? defaults.replyTo,
|
||||
content: input.content ?? { tiptap: EMPTY_TIPTAP },
|
||||
templateId: input.templateId ?? null,
|
||||
audience: input.audience ?? EMPTY_AUDIENCE,
|
||||
scheduledAt: null,
|
||||
sentAt: null,
|
||||
status: 'draft',
|
||||
serverJobId: null,
|
||||
stats: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await encryptRecord('broadcastCampaigns', newLocal);
|
||||
await campaignTable.add(newLocal);
|
||||
emitDomainEvent('BroadcastCampaignCreated', 'broadcast', 'broadcastCampaigns', newLocal.id, {
|
||||
campaignId: newLocal.id,
|
||||
name: newLocal.name,
|
||||
});
|
||||
return newLocal.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generic metadata patch — only valid in `draft`. Sending and onward
|
||||
* freeze the row to preserve the "what you saw is what went out"
|
||||
* invariant for the recipient.
|
||||
*/
|
||||
async updateCampaign(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
LocalCampaign,
|
||||
'name' | 'subject' | 'preheader' | 'fromName' | 'fromEmail' | 'replyTo' | 'templateId'
|
||||
>
|
||||
>
|
||||
) {
|
||||
const existing = await campaignTable.get(id);
|
||||
if (!existing) return;
|
||||
if (existing.status !== 'draft') {
|
||||
throw new Error('[broadcast] only drafts can be edited; duplicate to revise a sent campaign');
|
||||
}
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('broadcastCampaigns', wrapped);
|
||||
await campaignTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace content (Tiptap JSON + derived HTML/plaintext). Derived
|
||||
* outputs are passed in by the caller because rendering happens
|
||||
* client-side in the editor component; the store stays dumb about
|
||||
* Tiptap's schema.
|
||||
*/
|
||||
async updateContent(id: string, content: CampaignContent) {
|
||||
const existing = await campaignTable.get(id);
|
||||
if (!existing) return;
|
||||
if (existing.status !== 'draft') {
|
||||
throw new Error('[broadcast] only drafts can be edited');
|
||||
}
|
||||
const patch = { content } as Record<string, unknown>;
|
||||
await encryptRecord('broadcastCampaigns', patch);
|
||||
await campaignTable.update(id, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async updateAudience(id: string, audience: AudienceDefinition) {
|
||||
const existing = await campaignTable.get(id);
|
||||
if (!existing) return;
|
||||
if (existing.status !== 'draft') {
|
||||
throw new Error('[broadcast] only drafts can be edited');
|
||||
}
|
||||
const patch = { audience } as Record<string, unknown>;
|
||||
await encryptRecord('broadcastCampaigns', patch);
|
||||
await campaignTable.update(id, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip draft → scheduled with a future timestamp. Actual send happens
|
||||
* server-side when mana-mail's cron sees the row; this store just
|
||||
* arms the trigger.
|
||||
*/
|
||||
async schedule(id: string, scheduledAt: string) {
|
||||
const existing = await campaignTable.get(id);
|
||||
if (!existing) return;
|
||||
if (existing.status !== 'draft') return;
|
||||
await campaignTable.update(id, {
|
||||
status: 'scheduled' as CampaignStatus,
|
||||
scheduledAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('BroadcastCampaignScheduled', 'broadcast', 'broadcastCampaigns', id, {
|
||||
campaignId: id,
|
||||
scheduledAt,
|
||||
});
|
||||
},
|
||||
|
||||
/** Revoke a scheduled send before it fires. Can be reactivated as draft. */
|
||||
async cancel(id: string) {
|
||||
const existing = await campaignTable.get(id);
|
||||
if (!existing) return;
|
||||
if (existing.status !== 'draft' && existing.status !== 'scheduled') {
|
||||
throw new Error('[broadcast] only drafts or scheduled campaigns can be cancelled');
|
||||
}
|
||||
await campaignTable.update(id, {
|
||||
status: 'cancelled' as CampaignStatus,
|
||||
scheduledAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('BroadcastCampaignCancelled', 'broadcast', 'broadcastCampaigns', id, {
|
||||
campaignId: id,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Duplicate an existing campaign (typically a sent one being reused as
|
||||
* template-of-the-moment). Produces a fresh draft with the same
|
||||
* content + audience but new number / status.
|
||||
*/
|
||||
async duplicate(id: string): Promise<string> {
|
||||
const existing = await campaignTable.get(id);
|
||||
if (!existing) throw new Error('[broadcast] duplicate: source not found');
|
||||
const { decryptRecords } = await import('$lib/data/crypto');
|
||||
const [decrypted] = (await decryptRecords('broadcastCampaigns', [existing])) as LocalCampaign[];
|
||||
return this.createCampaign({
|
||||
name: `Kopie von ${decrypted.name}`,
|
||||
subject: decrypted.subject,
|
||||
preheader: decrypted.preheader,
|
||||
fromName: decrypted.fromName,
|
||||
fromEmail: decrypted.fromEmail,
|
||||
replyTo: decrypted.replyTo,
|
||||
content: decrypted.content,
|
||||
audience: decrypted.audience,
|
||||
templateId: decrypted.templateId,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteCampaign(id: string) {
|
||||
const existing = await campaignTable.get(id);
|
||||
if (!existing) return;
|
||||
if (existing.status === 'sending' || existing.status === 'sent') {
|
||||
throw new Error(
|
||||
'[broadcast] versendete oder laufende Kampagnen können nicht gelöscht werden (Bookkeeping)'
|
||||
);
|
||||
}
|
||||
await campaignTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('BroadcastCampaignDeleted', 'broadcast', 'broadcastCampaigns', id, {
|
||||
campaignId: id,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Server-side hook surface: once M4's orchestrator accepts a send, it
|
||||
* writes back here to reflect progress. Exposed as a store method so
|
||||
* callers share the encryption/event plumbing.
|
||||
*/
|
||||
async applyServerStatus(
|
||||
id: string,
|
||||
patch: {
|
||||
status: CampaignStatus;
|
||||
serverJobId?: string | null;
|
||||
sentAt?: string | null;
|
||||
stats?: LocalCampaign['stats'];
|
||||
}
|
||||
) {
|
||||
await campaignTable.update(id, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Broadcast settings store — singleton row per user/space.
|
||||
*
|
||||
* Same pattern as invoices settings: stable sentinel id, lazy create on
|
||||
* first read, plaintext structural fields stay out of the Dexie-tx crypto
|
||||
* boundary so ensure/get paths don't need crypto ops inside a transaction.
|
||||
*/
|
||||
|
||||
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { settingsTable } from '../collections';
|
||||
import { BROADCAST_SETTINGS_ID } from '../constants';
|
||||
import type { LocalBroadcastSettings, BroadcastSettings } from '../types';
|
||||
import { toSettings } from '../queries';
|
||||
|
||||
async function ensureSettings(): Promise<LocalBroadcastSettings> {
|
||||
const existing = await settingsTable.get(BROADCAST_SETTINGS_ID);
|
||||
if (existing) return existing;
|
||||
|
||||
const defaults: LocalBroadcastSettings = {
|
||||
id: BROADCAST_SETTINGS_ID,
|
||||
defaultFromName: '',
|
||||
defaultFromEmail: '',
|
||||
defaultReplyTo: null,
|
||||
defaultFooter: null,
|
||||
dnsCheck: null,
|
||||
legalAddress: '',
|
||||
unsubscribeLandingCopy: null,
|
||||
};
|
||||
const wrapped = { ...defaults };
|
||||
await encryptRecord('broadcastSettings', wrapped);
|
||||
await settingsTable.add(wrapped);
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export const broadcastSettingsStore = {
|
||||
async get(): Promise<BroadcastSettings> {
|
||||
await ensureSettings();
|
||||
const row = await settingsTable.get(BROADCAST_SETTINGS_ID);
|
||||
if (!row) throw new Error('[broadcast] settings row vanished after ensure');
|
||||
const [decrypted] = (await decryptRecords('broadcastSettings', [
|
||||
row,
|
||||
])) as LocalBroadcastSettings[];
|
||||
return toSettings(decrypted);
|
||||
},
|
||||
|
||||
async update(patch: Partial<Omit<LocalBroadcastSettings, 'id'>>) {
|
||||
await ensureSettings();
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('broadcastSettings', wrapped);
|
||||
await settingsTable.update(BROADCAST_SETTINGS_ID, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent(
|
||||
'BroadcastSettingsUpdated',
|
||||
'broadcast',
|
||||
'broadcastSettings',
|
||||
BROADCAST_SETTINGS_ID,
|
||||
{ fields: Object.keys(patch) }
|
||||
);
|
||||
},
|
||||
|
||||
/** Quick-read defaults for a fresh campaign. */
|
||||
async getDefaults(): Promise<{
|
||||
fromName: string;
|
||||
fromEmail: string;
|
||||
replyTo: string | null;
|
||||
footer: string | null;
|
||||
legalAddress: string;
|
||||
}> {
|
||||
const s = await this.get();
|
||||
return {
|
||||
fromName: s.defaultFromName,
|
||||
fromEmail: s.defaultFromEmail,
|
||||
replyTo: s.defaultReplyTo,
|
||||
footer: s.defaultFooter,
|
||||
legalAddress: s.legalAddress,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export { ensureSettings };
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
<!--
|
||||
ComposeView — 4-step wizard for creating / editing a campaign.
|
||||
|
||||
M2 ships steps 1 (Audience) + 2 (Content) fully functional. Preflight
|
||||
and Send steps are stubbed (buttons disabled) — those land in M3/M4
|
||||
when HTML rendering and bulk-send orchestration exist.
|
||||
|
||||
The wizard is state-aware: user can jump back to earlier steps, but
|
||||
step 3/4 refuse to activate if earlier steps are incomplete.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import AudienceBuilder from '../audience/AudienceBuilder.svelte';
|
||||
import Editor from '../editor/Editor.svelte';
|
||||
import { broadcastCampaignsStore } from '../stores/campaigns.svelte';
|
||||
import type { Campaign, CampaignContent, AudienceDefinition } from '../types';
|
||||
|
||||
interface Props {
|
||||
existing?: Campaign;
|
||||
}
|
||||
|
||||
let { existing }: Props = $props();
|
||||
// Capture once at mount — ComposeView is keyed on campaign id at the
|
||||
// route level, so prop changes remount rather than re-initialise.
|
||||
const initial = untrack(() => existing);
|
||||
const isEdit = untrack(() => Boolean(existing));
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
let step = $state<Step>(1);
|
||||
|
||||
// ─── Form state (captured once at mount) ───────────────────
|
||||
let name = $state<string>(initial?.name ?? 'Neue Kampagne');
|
||||
let subject = $state<string>(initial?.subject ?? '');
|
||||
let preheader = $state<string>(initial?.preheader ?? '');
|
||||
let fromName = $state<string>(initial?.fromName ?? '');
|
||||
let fromEmail = $state<string>(initial?.fromEmail ?? '');
|
||||
let audience = $state<AudienceDefinition>(
|
||||
initial?.audience ?? { filters: [], estimatedCount: 0 }
|
||||
);
|
||||
let content = $state<CampaignContent>(
|
||||
initial?.content ?? { tiptap: { type: 'doc', content: [{ type: 'paragraph' }] } }
|
||||
);
|
||||
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let savedAt = $state<string | null>(null);
|
||||
|
||||
// ─── Save ───────────────────────────────────────────────────
|
||||
async function save() {
|
||||
if (!existing) return;
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
await broadcastCampaignsStore.updateCampaign(existing.id, {
|
||||
name,
|
||||
subject,
|
||||
preheader: preheader || null,
|
||||
fromName,
|
||||
fromEmail,
|
||||
});
|
||||
await broadcastCampaignsStore.updateAudience(existing.id, audience);
|
||||
await broadcastCampaignsStore.updateContent(existing.id, content);
|
||||
savedAt = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Autosave on step change — keeps content from vanishing when the
|
||||
// user clicks through the wizard without hitting an explicit save.
|
||||
async function goToStep(next: Step) {
|
||||
if (isEdit) await save();
|
||||
step = next;
|
||||
}
|
||||
|
||||
const audienceReady = $derived(audience.estimatedCount > 0);
|
||||
const contentReady = $derived(subject.trim().length > 0);
|
||||
|
||||
function onCancel() {
|
||||
goto(isEdit && existing ? `/broadcasts/${existing.id}` : '/broadcasts');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="compose">
|
||||
<header class="head">
|
||||
<input
|
||||
class="name-input"
|
||||
type="text"
|
||||
placeholder="Kampagnen-Name"
|
||||
bind:value={name}
|
||||
aria-label="Kampagnen-Name"
|
||||
/>
|
||||
<div class="head-actions">
|
||||
{#if savedAt}
|
||||
<span class="saved">Gespeichert um {savedAt}</span>
|
||||
{/if}
|
||||
<button type="button" class="btn-ghost" onclick={onCancel}>Schließen</button>
|
||||
<button type="button" class="btn-primary" onclick={save} disabled={saving || !isEdit}>
|
||||
{saving ? 'Speichert …' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
class:active={step === 1}
|
||||
class:done={audienceReady && step > 1}
|
||||
onclick={() => goToStep(1)}
|
||||
>
|
||||
<span class="step-num">1</span>
|
||||
<span class="step-label">Empfänger</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
class:active={step === 2}
|
||||
class:done={contentReady && step > 2}
|
||||
onclick={() => goToStep(2)}
|
||||
>
|
||||
<span class="step-num">2</span>
|
||||
<span class="step-label">Inhalt</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
class:active={step === 3}
|
||||
disabled={!audienceReady || !contentReady}
|
||||
onclick={() => goToStep(3)}
|
||||
>
|
||||
<span class="step-num">3</span>
|
||||
<span class="step-label">Check</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
class:active={step === 4}
|
||||
disabled={!audienceReady || !contentReady}
|
||||
onclick={() => goToStep(4)}
|
||||
>
|
||||
<span class="step-num">4</span>
|
||||
<span class="step-label">Senden</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if step === 1}
|
||||
<section class="step-panel">
|
||||
<AudienceBuilder bind:audience />
|
||||
</section>
|
||||
{:else if step === 2}
|
||||
<section class="step-panel">
|
||||
<div class="meta-grid">
|
||||
<label class="field">
|
||||
<span>Betreff *</span>
|
||||
<input type="text" placeholder="Neuer Newsletter" bind:value={subject} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Preheader</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kurzer Vorschautext, erscheint in Gmail neben dem Betreff"
|
||||
bind:value={preheader}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Absender-Name *</span>
|
||||
<input type="text" bind:value={fromName} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Absender-E-Mail *</span>
|
||||
<input type="email" bind:value={fromEmail} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Editor
|
||||
bind:content
|
||||
placeholder="Schreib deinen Newsletter. Nutze Bilder, Überschriften und Links."
|
||||
/>
|
||||
</section>
|
||||
{:else if step === 3}
|
||||
<section class="step-panel">
|
||||
<div class="placeholder">
|
||||
<h3>Preflight</h3>
|
||||
<p>Spam-Score, DNS-Checks und Empfänger-Übersicht folgen in M3/M8.</p>
|
||||
<p class="hint">
|
||||
Empfänger: <strong>{audience.estimatedCount}</strong><br />
|
||||
Betreff: <strong>{subject || '—'}</strong><br />
|
||||
Absender: <strong>{fromName} <{fromEmail}></strong>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{:else if step === 4}
|
||||
<section class="step-panel">
|
||||
<div class="placeholder">
|
||||
<h3>Senden</h3>
|
||||
<p>
|
||||
Der Bulk-Send-Flow (Jetzt / Später) landet in M4 sobald mana-mail's <code>/bulk-send</code
|
||||
>-Endpoint steht.
|
||||
</p>
|
||||
<button class="btn-primary" disabled>Jetzt senden (M4)</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.compose {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.name-input {
|
||||
flex: 1;
|
||||
min-width: 18rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.name-input:hover,
|
||||
.name-input:focus {
|
||||
border-color: var(--color-border, #e2e8f0);
|
||||
background: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saved {
|
||||
font-size: 0.8rem;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--color-surface-muted, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.step:hover:not(:disabled) {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.step.active {
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.step.done .step-num {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-border, #e2e8f0);
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.active .step-num {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
background: var(--color-surface-muted, #f8fafc);
|
||||
border: 1px dashed var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.placeholder h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--color-text, #0f172a);
|
||||
}
|
||||
|
||||
.placeholder .hint {
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
padding: 0.55rem 1.25rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 0;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: white;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { useAllCampaigns } from '$lib/modules/broadcast/queries';
|
||||
import ComposeView from '$lib/modules/broadcast/views/ComposeView.svelte';
|
||||
|
||||
const campaigns$ = useAllCampaigns();
|
||||
const campaigns = $derived(campaigns$.value ?? []);
|
||||
const id = $derived($page.params.id);
|
||||
const campaign = $derived(campaigns.find((c) => c.id === id));
|
||||
const canEdit = $derived(campaign?.status === 'draft');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{campaign?.name ?? 'Kampagne'} - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !campaign && campaigns$.value !== undefined}
|
||||
<div class="not-found">
|
||||
<p>Kampagne nicht gefunden.</p>
|
||||
<a href="/broadcasts">Zurück zur Übersicht</a>
|
||||
</div>
|
||||
{:else if campaign && !canEdit}
|
||||
<div class="not-editable">
|
||||
<h2>Kampagne kann nicht bearbeitet werden</h2>
|
||||
<p>
|
||||
Status: <strong>{campaign.status}</strong>. Nur Entwürfe sind editierbar. Dupliziere die
|
||||
Kampagne, um eine neue Version zu erstellen.
|
||||
</p>
|
||||
<a href="/broadcasts">Zurück</a>
|
||||
</div>
|
||||
{:else if campaign}
|
||||
<ComposeView existing={campaign} />
|
||||
{:else}
|
||||
<div class="loading">Lädt …</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.not-found,
|
||||
.loading,
|
||||
.not-editable {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.not-editable h2 {
|
||||
color: var(--color-text, #0f172a);
|
||||
}
|
||||
|
||||
.not-editable p {
|
||||
max-width: 40ch;
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<!--
|
||||
/broadcasts/new — bootstraps a fresh draft and redirects to its
|
||||
edit view. Keeps the ComposeView stateful-only (no create/edit
|
||||
bifurcation inside the component itself).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { broadcastCampaignsStore } from '$lib/modules/broadcast/stores/campaigns.svelte';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const id = await broadcastCampaignsStore.createCampaign();
|
||||
goto(`/broadcasts/${id}/edit`, { replaceState: true });
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erstellen fehlgeschlagen';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neue Kampagne - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
<a href="/broadcasts">Zurück</a>
|
||||
{:else}
|
||||
<p class="loading">Kampagne wird angelegt …</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
|
|
@ -12,7 +13,9 @@
|
|||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
"rootDir": "./src",
|
||||
"noEmit": false,
|
||||
"rewriteRelativeImportExtensions": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
|
|
|||
777
pnpm-lock.yaml
generated
777
pnpm-lock.yaml
generated
|
|
@ -138,14 +138,14 @@ importers:
|
|||
version: link:../../../../packages/shared-landing-ui
|
||||
astro:
|
||||
specifier: ^5.16.0
|
||||
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
devDependencies:
|
||||
'@astrojs/tailwind':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.18
|
||||
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
|
@ -154,13 +154,13 @@ importers:
|
|||
version: 20.19.39
|
||||
eslint:
|
||||
specifier: ^9.0.0
|
||||
version: 9.39.4(jiti@1.21.7)
|
||||
version: 9.39.4(jiti@2.6.1)
|
||||
eslint-config-prettier:
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
|
||||
version: 9.1.2(eslint@9.39.4(jiti@2.6.1))
|
||||
eslint-plugin-astro:
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.0(eslint@9.39.4(jiti@1.21.7))
|
||||
version: 1.6.0(eslint@9.39.4(jiti@2.6.1))
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.8.1
|
||||
|
|
@ -253,10 +253,10 @@ importers:
|
|||
version: 3.7.2
|
||||
'@astrojs/tailwind':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
astro:
|
||||
specifier: ^5.16.11
|
||||
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
|
@ -576,6 +576,21 @@ importers:
|
|||
'@quotes/content':
|
||||
specifier: workspace:*
|
||||
version: link:../../../quotes/packages/content
|
||||
'@tiptap/core':
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-image':
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
'@types/pako':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
|
|
@ -7783,6 +7798,142 @@ packages:
|
|||
vitest:
|
||||
optional: true
|
||||
|
||||
'@tiptap/core@3.22.4':
|
||||
resolution: {integrity: sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-blockquote@3.22.4':
|
||||
resolution: {integrity: sha512-7/61kNPbGFhMgM//zMknD0pSb69rGdRIkpulXOWS1JBrFHkH6hjZDfrOETNzgKkO+NlmzVl9rXSTv0xauS3lzA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-bold@3.22.4':
|
||||
resolution: {integrity: sha512-jIaPKfNOQu2lhpbLDvtwlQqM+mjF+Kk+auHpzYjBnsuwUli1Cl5ZOau7RH+rru/SQvZe1DtpQlANujDywugZAA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-bullet-list@3.22.4':
|
||||
resolution: {integrity: sha512-TB+d3fGcTixYjO7coKqTr1mGTJuqr8hjDCPUFgzuvKyJnBhqWITmBzQ/8CLq4rr6mihgGURbD3N+xkQuPAKFiw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.22.4
|
||||
|
||||
'@tiptap/extension-code-block@3.22.4':
|
||||
resolution: {integrity: sha512-MEurzNXfMET3rhjpoPJYUgMfxTdTqbzT9+ToFrqNGAHocdXVm6m1hhO2frVC7fEtHPnxXKsn0Z3NUbCRkRTLuA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-code@3.22.4':
|
||||
resolution: {integrity: sha512-cnbxmVhAcc7X3G81QUYEmKP0ve2hRmvAiFXBuuv9RUtQlBiRnzmhHoJOMgkX0CsMR7+8kMRpTfeDUYq2xp5s5w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-document@3.22.4':
|
||||
resolution: {integrity: sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-dropcursor@3.22.4':
|
||||
resolution: {integrity: sha512-N9/yMDC35jJp0V/naL0+6gi4gUDUIcPpWEzFdCDWUSYBA8mt41c1kI1ZU7UTKYIBzTClenhYHRc2XKZxxx0+LQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.22.4
|
||||
|
||||
'@tiptap/extension-gapcursor@3.22.4':
|
||||
resolution: {integrity: sha512-UYBEUj3SFpKINIE7AdzcyeS3xICK+ee+YLBbuqNXyHStYChjJOohzJehqiqhjR16A88KQQ+ZjgyDcItKGygSog==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.22.4
|
||||
|
||||
'@tiptap/extension-hard-break@3.22.4':
|
||||
resolution: {integrity: sha512-xq+a4dE7T6VwApCkh/yU3p30gn3F8g8Arb9CyEZm58/WIJUIGvHSTjDdHmvU16+kiWSBg+wOOsaFHhYjJjxcKA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-heading@3.22.4':
|
||||
resolution: {integrity: sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.22.4':
|
||||
resolution: {integrity: sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-image@3.22.4':
|
||||
resolution: {integrity: sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-italic@3.22.4':
|
||||
resolution: {integrity: sha512-fVSDx5AYXgDI3v2zZIqb7V8EewthwM2NJ/ZCX+XaxRsqNEpnjVhgHs7UlvDqK1wj2OJ6zmUNjPtVlAFRxwT+HQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-link@3.22.4':
|
||||
resolution: {integrity: sha512-uoP3yus02uwGPVzW2QaEPJWVIrUb/r5nKm6c8DiJv9fNSX1+gykZZMg42c6GwRFLZ/vyfWjVCbAE03VMUqafgA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-list-item@3.22.4':
|
||||
resolution: {integrity: sha512-H659KXTvggSypIDWSOJBZ37jh9pKjQriDDvYPYvOZCdfij0D0hsDXN/wXoypArneUkoBdgruHfTtMkFOaQlgkw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.22.4
|
||||
|
||||
'@tiptap/extension-list-keymap@3.22.4':
|
||||
resolution: {integrity: sha512-t/zhker4oIS78AIGYDdFFfZC6zSBlszfD7z/zqFLGCg5PHNNgkZK5hKj6Vyix6D2SapRn/ajnx+8mhbKIUH5eA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.22.4
|
||||
|
||||
'@tiptap/extension-list@3.22.4':
|
||||
resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-ordered-list@3.22.4':
|
||||
resolution: {integrity: sha512-w77hPVf7pcHt97vfrybg/l0t5CimCd4y75OJKuHuo3CfgM5xbUP/gaPNMDyLLe7MYole/UHi/XvG3XjgzqTzAw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.22.4
|
||||
|
||||
'@tiptap/extension-paragraph@3.22.4':
|
||||
resolution: {integrity: sha512-de6dFkIhigiENESY6rNJ3yTVS/337ybfP30dNPudTwGe9oAu9ZCS+04j6QCvXSjhlI3ULiv7wiSHqrP26Gd+Hw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-placeholder@3.22.4':
|
||||
resolution: {integrity: sha512-Z3wtWL+KufwkC7CkJge5enAxx4q8C3oOYixme02snY9zfjX3V/1pjAmEfP4wxScgM5GIuTEJ83B9Yz3wRzPA6Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.22.4
|
||||
|
||||
'@tiptap/extension-strike@3.22.4':
|
||||
resolution: {integrity: sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-text@3.22.4':
|
||||
resolution: {integrity: sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extension-underline@3.22.4':
|
||||
resolution: {integrity: sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
|
||||
'@tiptap/extensions@3.22.4':
|
||||
resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/pm@3.22.4':
|
||||
resolution: {integrity: sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==}
|
||||
|
||||
'@tiptap/starter-kit@3.22.4':
|
||||
resolution: {integrity: sha512-qWjw+vfdin1rzMRpRU4cC5tLTwMJtUpXeQukv+6mOqqvhptuwuZBjUHImVEJaSPoHXS7+1ut+nTnrLyWyEuE5Q==}
|
||||
|
||||
'@tokenizer/inflate@0.2.7':
|
||||
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -12468,6 +12619,9 @@ packages:
|
|||
linkify-it@2.2.0:
|
||||
resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==}
|
||||
|
||||
linkifyjs@4.3.2:
|
||||
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||
|
||||
lint-staged@16.4.0:
|
||||
resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==}
|
||||
engines: {node: '>=20.17'}
|
||||
|
|
@ -13368,6 +13522,9 @@ packages:
|
|||
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
os-tmpdir@1.0.2:
|
||||
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -13888,6 +14045,42 @@ packages:
|
|||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
prosemirror-changeset@2.4.1:
|
||||
resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||
|
||||
prosemirror-view@1.41.8:
|
||||
resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -14470,6 +14663,9 @@ packages:
|
|||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
rou3@0.7.12:
|
||||
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
|
||||
|
||||
|
|
@ -15971,6 +16167,9 @@ packages:
|
|||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -16711,16 +16910,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
autoprefixer: 10.4.27(postcss@8.5.8)
|
||||
postcss: 8.5.8
|
||||
postcss-load-config: 4.0.2(postcss@8.5.8)
|
||||
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
|
|
@ -16741,6 +16930,16 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
autoprefixer: 10.4.27(postcss@8.5.8)
|
||||
postcss: 8.5.8
|
||||
postcss-load-config: 4.0.2(postcss@8.5.8)
|
||||
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
|
|
@ -18900,11 +19099,6 @@ snapshots:
|
|||
'@esbuild/win32-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
|
@ -22691,6 +22885,154 @@ snapshots:
|
|||
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@tiptap/core@3.22.4(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-blockquote@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-bold@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-bullet-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-code@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-document@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-dropcursor@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-gapcursor@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-hard-break@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-heading@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-image@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-italic@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-link@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
linkifyjs: 4.3.2
|
||||
|
||||
'@tiptap/extension-list-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-list-keymap@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-ordered-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-paragraph@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-strike@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-text@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-underline@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/pm@3.22.4':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.4.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.8
|
||||
|
||||
'@tiptap/starter-kit@3.22.4':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-blockquote': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-bold': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-bullet-list': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-code': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-code-block': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-document': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-dropcursor': 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-gapcursor': 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-hard-break': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-heading': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-horizontal-rule': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-italic': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-link': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-list-item': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-list-keymap': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-ordered-list': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-paragraph': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-strike': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-text': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-underline': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tokenizer/inflate@0.2.7':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -23186,7 +23528,7 @@ snapshots:
|
|||
obug: 2.1.1
|
||||
std-env: 4.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/expect@4.1.3':
|
||||
dependencies:
|
||||
|
|
@ -23248,7 +23590,7 @@ snapshots:
|
|||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/utils@4.1.3':
|
||||
dependencies:
|
||||
|
|
@ -23679,108 +24021,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
'@astrojs/internal-helpers': 0.7.6
|
||||
'@astrojs/markdown-remark': 6.3.11
|
||||
'@astrojs/telemetry': 3.3.0
|
||||
'@capsizecss/unpack': 4.0.0
|
||||
'@oslojs/encoding': 1.1.0
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||
acorn: 8.16.0
|
||||
aria-query: 5.3.2
|
||||
axobject-query: 4.1.0
|
||||
boxen: 8.0.1
|
||||
ci-info: 4.4.0
|
||||
clsx: 2.1.1
|
||||
common-ancestor-path: 1.0.1
|
||||
cookie: 1.1.1
|
||||
cssesc: 3.0.0
|
||||
debug: 4.4.3
|
||||
deterministic-object-hash: 2.0.2
|
||||
devalue: 5.7.0
|
||||
diff: 8.0.4
|
||||
dlv: 1.1.3
|
||||
dset: 3.1.4
|
||||
es-module-lexer: 1.7.0
|
||||
esbuild: 0.27.7
|
||||
estree-walker: 3.0.3
|
||||
flattie: 1.1.1
|
||||
fontace: 0.4.1
|
||||
github-slugger: 2.0.0
|
||||
html-escaper: 3.0.3
|
||||
http-cache-semantics: 4.2.0
|
||||
import-meta-resolve: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
magic-string: 0.30.21
|
||||
magicast: 0.5.2
|
||||
mrmime: 2.0.1
|
||||
neotraverse: 0.6.18
|
||||
p-limit: 6.2.0
|
||||
p-queue: 8.1.1
|
||||
package-manager-detector: 1.6.0
|
||||
piccolore: 0.1.3
|
||||
picomatch: 4.0.4
|
||||
prompts: 2.4.2
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
shiki: 3.23.0
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.1
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
ultrahtml: 1.6.0
|
||||
unifont: 0.7.4
|
||||
unist-util-visit: 5.1.0
|
||||
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
|
||||
vfile: 6.0.3
|
||||
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 21.1.1
|
||||
yocto-spinner: 0.2.3
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.2(zod@3.25.76)
|
||||
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
|
||||
optionalDependencies:
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
- '@azure/data-tables'
|
||||
- '@azure/identity'
|
||||
- '@azure/keyvault-secrets'
|
||||
- '@azure/storage-blob'
|
||||
- '@capacitor/preferences'
|
||||
- '@deno/kv'
|
||||
- '@netlify/blobs'
|
||||
- '@planetscale/database'
|
||||
- '@types/node'
|
||||
- '@upstash/redis'
|
||||
- '@vercel/blob'
|
||||
- '@vercel/functions'
|
||||
- '@vercel/kv'
|
||||
- aws4fetch
|
||||
- db0
|
||||
- idb-keyval
|
||||
- ioredis
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- rollup
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- typescript
|
||||
- uploadthing
|
||||
- yaml
|
||||
|
||||
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
|
|
@ -23985,6 +24225,108 @@ snapshots:
|
|||
- uploadthing
|
||||
- yaml
|
||||
|
||||
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
'@astrojs/internal-helpers': 0.7.6
|
||||
'@astrojs/markdown-remark': 6.3.11
|
||||
'@astrojs/telemetry': 3.3.0
|
||||
'@capsizecss/unpack': 4.0.0
|
||||
'@oslojs/encoding': 1.1.0
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||
acorn: 8.16.0
|
||||
aria-query: 5.3.2
|
||||
axobject-query: 4.1.0
|
||||
boxen: 8.0.1
|
||||
ci-info: 4.4.0
|
||||
clsx: 2.1.1
|
||||
common-ancestor-path: 1.0.1
|
||||
cookie: 1.1.1
|
||||
cssesc: 3.0.0
|
||||
debug: 4.4.3
|
||||
deterministic-object-hash: 2.0.2
|
||||
devalue: 5.7.0
|
||||
diff: 8.0.4
|
||||
dlv: 1.1.3
|
||||
dset: 3.1.4
|
||||
es-module-lexer: 1.7.0
|
||||
esbuild: 0.27.7
|
||||
estree-walker: 3.0.3
|
||||
flattie: 1.1.1
|
||||
fontace: 0.4.1
|
||||
github-slugger: 2.0.0
|
||||
html-escaper: 3.0.3
|
||||
http-cache-semantics: 4.2.0
|
||||
import-meta-resolve: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
magic-string: 0.30.21
|
||||
magicast: 0.5.2
|
||||
mrmime: 2.0.1
|
||||
neotraverse: 0.6.18
|
||||
p-limit: 6.2.0
|
||||
p-queue: 8.1.1
|
||||
package-manager-detector: 1.6.0
|
||||
piccolore: 0.1.3
|
||||
picomatch: 4.0.4
|
||||
prompts: 2.4.2
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
shiki: 3.23.0
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.1
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
ultrahtml: 1.6.0
|
||||
unifont: 0.7.4
|
||||
unist-util-visit: 5.1.0
|
||||
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
|
||||
vfile: 6.0.3
|
||||
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 21.1.1
|
||||
yocto-spinner: 0.2.3
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.2(zod@3.25.76)
|
||||
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
|
||||
optionalDependencies:
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
- '@azure/data-tables'
|
||||
- '@azure/identity'
|
||||
- '@azure/keyvault-secrets'
|
||||
- '@azure/storage-blob'
|
||||
- '@capacitor/preferences'
|
||||
- '@deno/kv'
|
||||
- '@netlify/blobs'
|
||||
- '@planetscale/database'
|
||||
- '@types/node'
|
||||
- '@upstash/redis'
|
||||
- '@vercel/blob'
|
||||
- '@vercel/functions'
|
||||
- '@vercel/kv'
|
||||
- aws4fetch
|
||||
- db0
|
||||
- idb-keyval
|
||||
- ioredis
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- rollup
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- typescript
|
||||
- uploadthing
|
||||
- yaml
|
||||
|
||||
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
|
|
@ -25788,11 +26130,6 @@ snapshots:
|
|||
eslint: 9.39.4(jiti@2.6.1)
|
||||
semver: 7.7.4
|
||||
|
||||
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
semver: 7.7.4
|
||||
|
||||
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
|
@ -25802,10 +26139,6 @@ snapshots:
|
|||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
||||
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
|
||||
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
|
@ -25850,20 +26183,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@typescript-eslint/types': 8.58.0
|
||||
astro-eslint-parser: 1.4.0
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7))
|
||||
globals: 16.5.0
|
||||
postcss: 8.5.8
|
||||
postcss-selector-parser: 7.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
|
|
@ -26037,47 +26356,6 @@ snapshots:
|
|||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@9.39.4(jiti@1.21.7):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.2
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/eslintrc': 3.3.5
|
||||
'@eslint/js': 9.39.4
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
'@humanfs/node': 0.16.7
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
ajv: 6.14.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
esquery: 1.7.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
file-entry-cache: 8.0.0
|
||||
find-up: 5.0.0
|
||||
glob-parent: 6.0.2
|
||||
ignore: 5.3.2
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 1.21.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint@9.39.4(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
|
|
@ -28777,6 +29055,8 @@ snapshots:
|
|||
dependencies:
|
||||
uc.micro: 1.0.6
|
||||
|
||||
linkifyjs@4.3.2: {}
|
||||
|
||||
lint-staged@16.4.0:
|
||||
dependencies:
|
||||
commander: 14.0.3
|
||||
|
|
@ -30216,6 +30496,8 @@ snapshots:
|
|||
strip-ansi: 6.0.1
|
||||
wcwidth: 1.0.1
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
os-tmpdir@1.0.2: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
|
|
@ -30667,6 +30949,75 @@ snapshots:
|
|||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
prosemirror-changeset@2.4.1:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.8
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.8
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.8
|
||||
rope-sequence: 1.3.4
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.8
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.8
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-view@1.41.8:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
protobufjs@7.5.4:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
|
|
@ -31614,6 +31965,8 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
rou3@0.7.12: {}
|
||||
|
||||
router@2.2.0:
|
||||
|
|
@ -33056,23 +33409,6 @@ snapshots:
|
|||
lightningcss: 1.32.0
|
||||
terser: 5.46.1
|
||||
|
||||
vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.8
|
||||
rollup: 4.60.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.39
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
lightningcss: 1.32.0
|
||||
terser: 5.46.1
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
|
|
@ -33107,6 +33443,23 @@ snapshots:
|
|||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.8
|
||||
rollup: 4.60.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.12.2
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
lightningcss: 1.32.0
|
||||
terser: 5.46.1
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
|
|
@ -33124,10 +33477,6 @@ snapshots:
|
|||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
|
@ -33136,6 +33485,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
|
@ -33334,6 +33687,8 @@ snapshots:
|
|||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue