mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 21:16:41 +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,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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
})
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue