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' },
},
})
),
],