feat(articles): M7 share-target + bookmarklet — save from anywhere

@mana/shared-pwa gains PWAShareTarget + PWAShareTargetParams types
plus ManifestConfig.share_target pass-through. createPWAConfig now
accepts an optional `shareTarget` and threads it into the generated
manifest. Other apps keep working unchanged — the field is omitted
unless set.

Web app wiring:
 - vite.config.ts passes shareTarget: { action: '/articles/add',
   method: 'GET', params: { title, text, url } } so the installed PWA
   shows up as a destination in the Android / Chromium share sheet.
 - AddUrlForm reads ?url / ?text / ?title in onMount; falls back to
   the first URL-shaped token in ?text because some senders (Chrome
   Android, WhatsApp) put the shared link there instead of ?url. When
   a URL is pre-filled the Readability preview auto-triggers, so the
   user just hits "In Leseliste speichern" to confirm.
 - New /articles/settings route hosts the bookmarklet (drag-to-
   bookmarks-bar button + copy-to-clipboard + expandable snippet
   viewer) and a short Share-Target explainer with an iOS-Safari
   caveat. Linked from the ListView via a new gear button next to
   "+ Neu speichern".

Bookmarklet form (origin-prefixed so it works across tenants):
  javascript:void(window.open('${origin}/articles/add?url='+…))

Not in scope (plan marked optional): _pendingUrls offline queue.
Share without internet shows the existing error + retry state today;
can slot in as M7b if users hit it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 19:03:33 +02:00
parent 831c30eaa7
commit 8a991f7c39
8 changed files with 348 additions and 6 deletions

View file

@ -79,9 +79,20 @@
<h1>Artikel</h1> <h1>Artikel</h1>
<p class="subtitle">Später lesen — gespeicherte Web-Artikel, offline verfügbar.</p> <p class="subtitle">Später lesen — gespeicherte Web-Artikel, offline verfügbar.</p>
</div> </div>
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}> <div class="header-actions">
+ Neu speichern <button
</button> type="button"
class="icon-btn"
title="Einstellungen — Bookmarklet + Share-Target"
aria-label="Artikel-Einstellungen"
onclick={() => goto('/articles/settings')}
>
</button>
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}>
+ Neu speichern
</button>
</div>
</div> </div>
<div class="filter-row" role="tablist" aria-label="Filter"> <div class="filter-row" role="tablist" aria-label="Filter">
@ -175,6 +186,26 @@
margin: 0 0 0.25rem 0; margin: 0 0 0.25rem 0;
font-size: 1.75rem; font-size: 1.75rem;
} }
.header-actions {
display: flex;
gap: 0.4rem;
align-items: center;
flex-shrink: 0;
}
.icon-btn {
padding: 0.5rem 0.65rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.icon-btn:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.add-btn { .add-btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.55rem; border-radius: 0.55rem;

View file

@ -2,7 +2,9 @@
AddUrlForm — paste URL → preview → save. AddUrlForm — paste URL → preview → save.
Flow: Flow:
1. User pastes (or types) a URL 1. User pastes (or types) a URL, OR the page is opened with a URL
pre-filled via query string (?url=… / ?text=… / ?title=…). The
Web Share Target + bookmarklet both land here that way.
2. On "Vorschau abrufen": check scope-local dedupe first; if found, 2. On "Vorschau abrufen": check scope-local dedupe first; if found,
offer "öffnen" instead of re-extracting (saves one round-trip). offer "öffnen" instead of re-extracting (saves one round-trip).
Otherwise call /api/v1/articles/extract and render the preview. Otherwise call /api/v1/articles/extract and render the preview.
@ -10,9 +12,15 @@
articlesStore.saveFromExtracted — no second server call. articlesStore.saveFromExtracted — no second server call.
4. Navigate into the new article so the user lands directly in the 4. Navigate into the new article so the user lands directly in the
reader view. reader view.
Pre-filled URLs auto-trigger the preview on mount so the three-click
"share from browser → saved" flow really is three clicks: share →
pick Mana → hit "In Leseliste speichern".
--> -->
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { articlesStore } from '../stores/articles.svelte'; import { articlesStore } from '../stores/articles.svelte';
import { extractArticle, type ExtractedArticle } from '../api'; import { extractArticle, type ExtractedArticle } from '../api';
import type { Article } from '../types'; import type { Article } from '../types';
@ -31,6 +39,29 @@
node.focus(); node.focus();
} }
/**
* Extract the first URL-shaped token from a string — some share
* senders (Chrome Android, WhatsApp) stuff the URL into the `text`
* slot instead of `url`, often prefixed with the page title.
*/
function firstUrl(text: string): string {
const m = text.match(/https?:\/\/\S+/i);
return m ? m[0] : '';
}
onMount(() => {
const params = $page.url.searchParams;
const fromUrl = params.get('url')?.trim() ?? '';
const fromText = params.get('text')?.trim() ?? '';
const candidate = fromUrl || firstUrl(fromText);
if (candidate) {
url = candidate;
// Fire-and-forget — the handler is idempotent enough that a
// stray second click does no harm.
void handlePreview();
}
});
function reset() { function reset() {
preview = null; preview = null;
duplicate = null; duplicate = null;

View file

@ -0,0 +1,208 @@
<!--
/articles/settings — collection of "how to save faster" tips.
Two surfaces today:
1. Bookmarklet — one-click save from any desktop browser. The user
drags the button into their bookmarks bar; clicking it opens
/articles/add?url=<current-page> in a new tab.
2. Share Target — installed PWA appears in the OS share sheet on
Android / Chromium desktop. Same landing route as the bookmarklet.
Both end up in AddUrlForm, which reads the ?url / ?text / ?title query
params, auto-triggers the Readability preview, and drops the user one
click away from "In Leseliste speichern".
-->
<script lang="ts">
import { onMount } from 'svelte';
// `origin` at render time — server-side rendering has no window, so
// we read it client-side after mount. The bookmarklet embeds the
// origin so the JavaScript URL works from any other origin's bookmark
// bar.
let origin = $state('');
onMount(() => {
origin = window.location.origin;
});
const bookmarklet = $derived(
origin
? `javascript:void(window.open('${origin}/articles/add?url='+encodeURIComponent(location.href)+'&title='+encodeURIComponent(document.title),'_blank'))`
: ''
);
let copyLabel = $state('Snippet kopieren');
async function copySnippet() {
if (!bookmarklet) return;
try {
await navigator.clipboard.writeText(bookmarklet);
copyLabel = 'Kopiert ✓';
setTimeout(() => (copyLabel = 'Snippet kopieren'), 1500);
} catch {
copyLabel = 'Fehler — bitte manuell kopieren';
}
}
</script>
<svelte:head>
<title>Artikel-Einstellungen — Mana</title>
</svelte:head>
<div class="settings-shell">
<header class="header">
<h1>Artikel-Einstellungen</h1>
<p class="subtitle">Schnellwege, um Artikel aus dem Browser in die Leseliste zu bekommen.</p>
</header>
<section class="card">
<h2>Bookmarklet</h2>
<p>
Zieh den Button unten in deine Lesezeichen-Leiste. Ein Klick auf einer beliebigen Webseite
öffnet
<code>/articles/add</code> mit der aktuellen URL vorausgefüllt — du bestätigst nur noch die Vorschau.
</p>
<div class="bookmarklet-row">
<!-- The anchor IS the bookmarklet: its href is the javascript: -->
<!-- snippet. We sanity-check it client-side (origin-prefixed and -->
<!-- opens a new tab) before persisting state, so there's no XSS -->
<!-- surface here beyond what the browser already allows on any -->
<!-- javascript: bookmark. -->
{#if bookmarklet}
<a class="bookmarklet" href={bookmarklet} onclick={(e) => e.preventDefault()}>
+ In Mana speichern
</a>
{:else}
<span class="muted">Bookmarklet wird geladen…</span>
{/if}
<button type="button" class="copy-btn" onclick={copySnippet} disabled={!bookmarklet}>
{copyLabel}
</button>
</div>
<details class="snippet-details">
<summary>Quellcode anzeigen</summary>
<pre class="snippet">{bookmarklet}</pre>
</details>
<p class="hint">
Funktioniert in jedem Desktop-Browser. In Safari: Lesezeichen anlegen mit einer beliebigen
URL, dann nachträglich die URL durch das Snippet ersetzen.
</p>
</section>
<section class="card">
<h2>Share-Target (Android / Chromium)</h2>
<p>
Wenn du Mana als App installierst (Browser-Menü „Zum Startbildschirm hinzufügen"), taucht
„Mana" in deinem OS-Share-Sheet auf. Teilen aus dem Browser oder einer anderen App → Mana
auswählen → Artikel wird direkt in der Leseliste vorgeschlagen.
</p>
<p class="hint">
iOS-Safari unterstützt die Web-Share-Target-API derzeit nicht — nutze dort das Bookmarklet.
</p>
</section>
</div>
<style>
.settings-shell {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.6rem;
}
.subtitle {
color: var(--color-text-muted, #64748b);
margin: 0;
font-size: 0.95rem;
}
.card {
padding: 1.1rem 1.2rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.75rem;
background: var(--color-surface, transparent);
}
.card h2 {
margin: 0 0 0.5rem 0;
font-size: 1.15rem;
}
.card p {
margin: 0 0 0.75rem 0;
line-height: 1.55;
}
.hint {
color: var(--color-text-muted, #64748b);
font-size: 0.85rem;
}
code {
padding: 0.1rem 0.35rem;
border-radius: 0.3rem;
background: color-mix(in srgb, currentColor 8%, transparent);
font-size: 0.92em;
}
.bookmarklet-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0.75rem 0;
flex-wrap: wrap;
}
.bookmarklet {
padding: 0.5rem 1rem;
border-radius: 0.55rem;
background: #f97316;
color: white;
font-weight: 500;
text-decoration: none;
user-select: none;
cursor: grab;
display: inline-flex;
align-items: center;
}
.bookmarklet:hover {
background: #ea580c;
}
.copy-btn {
padding: 0.5rem 0.85rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.copy-btn:hover:not(:disabled) {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.copy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: var(--color-text-muted, #64748b);
}
.snippet-details {
margin-top: 0.5rem;
}
.snippet-details summary {
cursor: pointer;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.snippet {
margin: 0.5rem 0 0 0;
padding: 0.6rem 0.75rem;
border-radius: 0.45rem;
background: color-mix(in srgb, currentColor 6%, transparent);
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.78rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View file

@ -49,6 +49,17 @@ export default defineConfig({
}, },
{ name: 'Chat', short_name: 'Chat', url: '/chat', description: 'Chat öffnen' }, { name: 'Chat', short_name: 'Chat', url: '/chat', description: 'Chat öffnen' },
], ],
// Web Share Target — installed PWA shows up in the OS share
// sheet as "Mana" and lands on /articles/add with the URL
// pre-filled (AddUrlForm reads ?url + ?text + ?title). The
// `text` param is listed alongside `url` because some
// senders (Chrome Android, WhatsApp) stuff the URL into the
// text field.
shareTarget: {
action: '/articles/add',
method: 'GET',
params: { title: 'title', text: 'text', url: 'url' },
},
}) })
), ),
], ],

View file

@ -12,11 +12,15 @@
**M5 Migration von news:type='saved': DONE** (commit `04293ed5e`) — Boot-gated Migration in `modules/articles/migrations/from-news.ts` (localStorage-Sentinel `mana:articles:from-news-migration:v1`), decrypt→re-encrypt zwischen den beiden Field-Allowlists, Status-Mapping `isArchived→archived` / `isRead→finished` / sonst `unread`, Source-Rows werden soft-deletet. News-Code deprecated: `saveFromUrl` + `extractFromUrl` entfernt, `save_news_article` AI-Tool behält seinen Namen (wegen Mission-History) und leitet intern aufs `articles`-Modul um. `/news/add` + `/news/saved` sind Redirects. `news-research` „Speichern"-Buttons routen auf `/articles/[id]`. **M5 Migration von news:type='saved': DONE** (commit `04293ed5e`) — Boot-gated Migration in `modules/articles/migrations/from-news.ts` (localStorage-Sentinel `mana:articles:from-news-migration:v1`), decrypt→re-encrypt zwischen den beiden Field-Allowlists, Status-Mapping `isArchived→archived` / `isRead→finished` / sonst `unread`, Source-Rows werden soft-deletet. News-Code deprecated: `saveFromUrl` + `extractFromUrl` entfernt, `save_news_article` AI-Tool behält seinen Namen (wegen Mission-History) und leitet intern aufs `articles`-Modul um. `/news/add` + `/news/saved` sind Redirects. `news-research` „Speichern"-Buttons routen auf `/articles/[id]`.
**M6 AI-Tools: DONE** (commit pending) — 5 neue Einträge im `AI_TOOL_CATALOG` (`shared-ai/src/tools/schemas.ts`): `list_articles` (auto), `save_article` / `archive_article` / `tag_article` / `add_article_highlight` (alle propose). `modules/articles/tools.ts` enthält die `execute`-Funktionen, registriert in `data/tools/init.ts`. `tag_article` dedupliziert case-insensitive über den globalen Pool und legt Tags via `tagMutations.createTag` an falls nötig. `add_article_highlight` snappt auf die erste wörtliche Fundstelle in `article.content` und lehnt den Call ab wenn der Text nicht exakt vorkommt (kein Orphan-Highlight). Policy/Executor/Server-Planner leiten sich automatisch aus dem Katalog ab. **M6 AI-Tools: DONE** (commit `5924f4fac`) — 5 neue Einträge im `AI_TOOL_CATALOG` (`shared-ai/src/tools/schemas.ts`): `list_articles` (auto), `save_article` / `archive_article` / `tag_article` / `add_article_highlight` (alle propose). `modules/articles/tools.ts` enthält die `execute`-Funktionen, registriert in `data/tools/init.ts`. `tag_article` dedupliziert case-insensitive über den globalen Pool und legt Tags via `tagMutations.createTag` an falls nötig. `add_article_highlight` snappt auf die erste wörtliche Fundstelle in `article.content` und lehnt den Call ab wenn der Text nicht exakt vorkommt (kein Orphan-Highlight). Policy/Executor/Server-Planner leiten sich automatisch aus dem Katalog ab.
**Hinweis AiProposalInbox:** Der apps/mana/CLAUDE.md-Abschnitt erwähnt `<AiProposalInbox module="articles" />` als Inline-Mount, aber die Komponente existiert im aktuellen Codebase nicht — nach dem `pendingProposals`-Table-Drop in Dexie v29 wurde die Proposal-Darstellung auf `server-iteration-staging` + den Cross-Module-Inbox im Mission-Detail-View umgestellt. Articles-Proposals tauchen dort automatisch auf. Falls die Inline-Komponente wieder reaktiviert wird, muss nur der Mount in `ListView.svelte` ergänzt werden. **Hinweis AiProposalInbox:** Der apps/mana/CLAUDE.md-Abschnitt erwähnt `<AiProposalInbox module="articles" />` als Inline-Mount, aber die Komponente existiert im aktuellen Codebase nicht — nach dem `pendingProposals`-Table-Drop in Dexie v29 wurde die Proposal-Darstellung auf `server-iteration-staging` + den Cross-Module-Inbox im Mission-Detail-View umgestellt. Articles-Proposals tauchen dort automatisch auf. Falls die Inline-Komponente wieder reaktiviert wird, muss nur der Mount in `ListView.svelte` ergänzt werden.
Nächster Schritt: M7 (Share-Target + Bookmarklet) oder M8 (HighlightsView + Stats + Dashboard-Widget). **M7 Share-Target + Bookmarklet: DONE** (commit pending) — `@mana/shared-pwa` bekommt neue Types (`PWAShareTarget`, `PWAShareTargetParams`), `createPWAConfig` threadet `shareTarget` in den Manifest, `ManifestConfig.share_target?` ergänzt. Web-App: `vite.config.ts` setzt `shareTarget: { action: '/articles/add', method: 'GET', params: { title, text, url } }`; `AddUrlForm` liest Query-Params in `onMount` (inkl. URL-Regex-Fallback auf `text` weil Chrome Android / WhatsApp den Link dort reinstecken), triggert auto-Vorschau. Neue Route `/articles/settings` rendert Bookmarklet-Karte (Drag-to-Bookmark + Copy-Snippet + expandable Quellcode) und Share-Target-Erklärung. `ListView` bekommt Zahnrad-Button zum Settings-Aufruf.
Nicht im Scope (bewusst ausgelassen): die „optional" im Plan markierte `_pendingUrls`-Offline-Queue. Kann als M7b nachgereicht werden wenn das Problem auftaucht.
Nächster Schritt: M8 (HighlightsView + Stats + Dashboard-Widget).
## Ziel ## Ziel

View file

@ -48,6 +48,7 @@ export function createPWAConfig(options: PWAConfigOptions): PWAConfig {
backgroundColor = DEFAULT_BACKGROUND_COLOR, backgroundColor = DEFAULT_BACKGROUND_COLOR,
preset = 'standard', preset = 'standard',
shortcuts = [], shortcuts = [],
shareTarget,
categories = DEFAULT_CATEGORIES, categories = DEFAULT_CATEGORIES,
includeAssets = [], includeAssets = [],
globIgnores = [], globIgnores = [],
@ -91,6 +92,17 @@ export function createPWAConfig(options: PWAConfigOptions): PWAConfig {
})); }));
} }
// Web Share Target — lets installed PWAs appear in the OS share
// sheet. Browsers that don't support the spec ignore the field.
if (shareTarget) {
manifest.share_target = {
action: shareTarget.action,
method: shareTarget.method ?? 'GET',
enctype: shareTarget.enctype,
params: shareTarget.params,
};
}
// Build workbox config // Build workbox config
const workbox: WorkboxConfig = { const workbox: WorkboxConfig = {
globPatterns: DEFAULT_GLOB_PATTERNS, globPatterns: DEFAULT_GLOB_PATTERNS,

View file

@ -46,6 +46,8 @@ export type {
PWAConfigOptions, PWAConfigOptions,
PWAConfig, PWAConfig,
PWAShortcut, PWAShortcut,
PWAShareTarget,
PWAShareTargetParams,
WorkboxPreset, WorkboxPreset,
ManifestConfig, ManifestConfig,
ManifestIcon, ManifestIcon,

View file

@ -20,6 +20,36 @@ export interface PWAShortcut {
url: string; url: string;
} }
/**
* Web Share Target API configuration. When an installed PWA declares
* a share target, the OS share sheet offers the app as a destination
* for URLs / text / titles. The browser invokes `action` with the
* selected data mapped to the `params` field names.
*
* Reference: https://www.w3.org/TR/web-share-target/
* Browser support: Chromium (Android + desktop installed PWAs).
* Safari / Firefox ignore the field gracefully.
*/
export interface PWAShareTargetParams {
/** Query/form param that carries the shared page title. */
title?: string;
/** Query/form param for free-form shared text. */
text?: string;
/** Query/form param for the shared URL. */
url?: string;
}
export interface PWAShareTarget {
/** In-app URL the browser should navigate to with the shared payload. */
action: string;
/** HTTP method. GET maps data to query params, POST to form data. */
method?: 'GET' | 'POST';
/** Required for method=POST — typical value: 'multipart/form-data'. */
enctype?: string;
/** Maps the spec's title/text/url slots onto your own param names. */
params: PWAShareTargetParams;
}
/** /**
* Configuration options for createPWAConfig * Configuration options for createPWAConfig
*/ */
@ -67,6 +97,13 @@ export interface PWAConfigOptions {
*/ */
shortcuts?: PWAShortcut[]; shortcuts?: PWAShortcut[];
/**
* Web Share Target config. Installed PWA becomes a destination in
* the OS share sheet for URLs / text / titles. Ignored by browsers
* that don't support the spec (Safari / Firefox).
*/
shareTarget?: PWAShareTarget;
/** /**
* App categories for store listings * App categories for store listings
* @default ["productivity", "utilities"] * @default ["productivity", "utilities"]
@ -165,6 +202,12 @@ export interface ManifestConfig {
url: string; url: string;
icons?: Array<{ src: string; sizes: string }>; icons?: Array<{ src: string; sizes: string }>;
}>; }>;
share_target?: {
action: string;
method: 'GET' | 'POST';
enctype?: string;
params: PWAShareTargetParams;
};
} }
/** /**