mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
831c30eaa7
commit
8a991f7c39
8 changed files with 348 additions and 6 deletions
|
|
@ -79,10 +79,21 @@
|
|||
<h1>Artikel</h1>
|
||||
<p class="subtitle">Später lesen — gespeicherte Web-Artikel, offline verfügbar.</p>
|
||||
</div>
|
||||
<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">
|
||||
{#each FILTERS as f (f.id)}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
})
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export type {
|
|||
PWAConfigOptions,
|
||||
PWAConfig,
|
||||
PWAShortcut,
|
||||
PWAShareTarget,
|
||||
PWAShareTargetParams,
|
||||
WorkboxPreset,
|
||||
ManifestConfig,
|
||||
ManifestIcon,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue