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>
<p class="subtitle">Später lesen — gespeicherte Web-Artikel, offline verfügbar.</p>
</div>
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}>
+ Neu speichern
</button>
<div class="header-actions">
<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 class="filter-row" role="tablist" aria-label="Filter">
@ -175,6 +186,26 @@
margin: 0 0 0.25rem 0;
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 {
padding: 0.5rem 1rem;
border-radius: 0.55rem;

View file

@ -2,7 +2,9 @@
AddUrlForm — paste URL → preview → save.
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,
offer "öffnen" instead of re-extracting (saves one round-trip).
Otherwise call /api/v1/articles/extract and render the preview.
@ -10,9 +12,15 @@
articlesStore.saveFromExtracted — no second server call.
4. Navigate into the new article so the user lands directly in the
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">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { articlesStore } from '../stores/articles.svelte';
import { extractArticle, type ExtractedArticle } from '../api';
import type { Article } from '../types';
@ -31,6 +39,29 @@
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() {
preview = 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' },
],
// 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]`.
**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.
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

View file

@ -48,6 +48,7 @@ export function createPWAConfig(options: PWAConfigOptions): PWAConfig {
backgroundColor = DEFAULT_BACKGROUND_COLOR,
preset = 'standard',
shortcuts = [],
shareTarget,
categories = DEFAULT_CATEGORIES,
includeAssets = [],
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
const workbox: WorkboxConfig = {
globPatterns: DEFAULT_GLOB_PATTERNS,

View file

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

View file

@ -20,6 +20,36 @@ export interface PWAShortcut {
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
*/
@ -67,6 +97,13 @@ export interface PWAConfigOptions {
*/
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
* @default ["productivity", "utilities"]
@ -165,6 +202,12 @@ export interface ManifestConfig {
url: string;
icons?: Array<{ src: string; sizes: string }>;
}>;
share_target?: {
action: string;
method: 'GET' | 'POST';
enctype?: string;
params: PWAShareTargetParams;
};
}
/**