Skip to content

Commit b0dc72b

Browse files
committed
add pwa share target
1 parent e1188e7 commit b0dc72b

3 files changed

Lines changed: 208 additions & 10 deletions

File tree

frontend/src/routes/save/+page.svelte

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,41 @@
2121
let errorMessage = $state<string | null>(null);
2222
let limitInfo = $state<{ limit: number; resetsAt: string } | null>(null);
2323
24-
// The article URL from the query string. searchParams decodes once, so a
25-
// Shortcut that percent-encodes the URL lands here as a clean absolute URL.
26-
function readUrl(): string | null {
27-
const raw = $page.url.searchParams.get('url');
28-
if (!raw) return null;
29-
const trimmed = raw.trim();
24+
// Pull a clean http(s) URL out of a shared string. A Shortcut/bookmarklet
25+
// sends a bare URL; the Web Share Target on Android often delivers it inside
26+
// `text`, sometimes wrapped in other words ("Great read https://…"), so fall
27+
// back to scanning for the first URL.
28+
function extractHttpUrl(raw: string): string | null {
29+
const s = raw.trim();
30+
if (!s) return null;
3031
try {
31-
const parsed = new URL(trimmed);
32-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
33-
return trimmed;
32+
const parsed = new URL(s);
33+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return s;
3434
} catch {
35-
return null;
35+
// Not a bare URL; scan the text for one below.
36+
}
37+
const match = s.match(/https?:\/\/\S+/i);
38+
if (match) {
39+
try {
40+
const parsed = new URL(match[0]);
41+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return match[0];
42+
} catch {
43+
// Fall through.
44+
}
45+
}
46+
return null;
47+
}
48+
49+
// The article URL from the query string. Accepts the `url` param (Shortcut /
50+
// bookmarklet) or the `text` param (PWA share_target / Android share sheet).
51+
function readUrl(): string | null {
52+
const params = $page.url.searchParams;
53+
for (const raw of [params.get('url'), params.get('text')]) {
54+
if (!raw) continue;
55+
const found = extractHttpUrl(raw);
56+
if (found) return found;
3657
}
58+
return null;
3759
}
3860
3961
async function run() {

frontend/src/routes/settings/+page.svelte

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { onMount } from 'svelte';
3+
import { browser } from '$app/environment';
34
import { goto } from '$app/navigation';
45
import { auth } from '$lib/stores/auth.svelte';
56
import { subscriptionsStore } from '$lib/stores/subscriptions.svelte';
@@ -39,6 +40,41 @@
3940
4041
let showImportModal = $state(false);
4142
43+
// "Save from anywhere" — browser bookmarklets + a Share Sheet shortcut.
44+
// Paste a published iCloud Shortcut link (icloud.com/shortcuts/...) into either
45+
// constant to turn the manual steps into a one-tap "Add Shortcut" button.
46+
const APPLE_SAVE_SHORTCUT_URL =
47+
'https://www.icloud.com/shortcuts/ead7df12455949fa92271ec3d0bea3f7';
48+
const APPLE_SUBSCRIBE_SHORTCUT_URL =
49+
'https://www.icloud.com/shortcuts/4b70e834a8ae48ee8c039cbf01e5b8c4';
50+
51+
// Build links against the current origin so they also work on staging/local.
52+
const appOrigin = browser ? window.location.origin : 'https://skyreader.app';
53+
const saveBookmarklet = `javascript:void(window.open('${appOrigin}/save?url='+encodeURIComponent(location.href)))`;
54+
const subscribeBookmarklet = `javascript:void(window.open('${appOrigin}/subscribe?url='+encodeURIComponent(location.href)))`;
55+
56+
let copiedKey = $state<string | null>(null);
57+
let bookmarkletHint = $state(false);
58+
59+
async function copyText(text: string, key: string) {
60+
try {
61+
await navigator.clipboard.writeText(text);
62+
copiedKey = key;
63+
setTimeout(() => {
64+
if (copiedKey === key) copiedKey = null;
65+
}, 1500);
66+
} catch {
67+
// Clipboard unavailable (e.g. insecure context); the drag target still works.
68+
}
69+
}
70+
71+
// A bookmarklet is meant to be dragged to the bookmarks bar, not clicked here:
72+
// the app's CSP blocks the javascript: navigation anyway. Nudge the user.
73+
function showDragHint(e: MouseEvent) {
74+
e.preventDefault();
75+
bookmarkletHint = true;
76+
}
77+
4278
// PDS Sync state
4379
let pdsSyncEnabled = $state(false);
4480
let lastSyncSubscriptions = $state<number | null>(null);
@@ -548,6 +584,75 @@
548584
</div>
549585
</section>
550586

587+
<section class="card">
588+
<h2>Save from anywhere</h2>
589+
<p>Save an article or subscribe to a feed without leaving the page you're reading.</p>
590+
591+
<h3 class="subhead">On your computer</h3>
592+
<p class="hint-text">Drag a button to your bookmarks bar, then click it on any page:</p>
593+
<div class="bookmarklet-row">
594+
<a class="bookmarklet" href={saveBookmarklet} onclick={showDragHint}>Save to Skyreader</a>
595+
<a class="bookmarklet" href={subscribeBookmarklet} onclick={showDragHint}>
596+
Subscribe in Skyreader
597+
</a>
598+
</div>
599+
{#if bookmarkletHint}
600+
<p class="hint-text">Drag these up to your bookmarks bar. Clicking here won't run them.</p>
601+
{/if}
602+
<div class="button-row">
603+
<button class="btn btn-secondary" onclick={() => copyText(saveBookmarklet, 'save')}>
604+
{copiedKey === 'save' ? 'Copied' : 'Copy Save link'}
605+
</button>
606+
<button class="btn btn-secondary" onclick={() => copyText(subscribeBookmarklet, 'subscribe')}>
607+
{copiedKey === 'subscribe' ? 'Copied' : 'Copy Subscribe link'}
608+
</button>
609+
</div>
610+
611+
<h3 class="subhead">On iPhone or iPad</h3>
612+
{#if APPLE_SAVE_SHORTCUT_URL || APPLE_SUBSCRIBE_SHORTCUT_URL}
613+
<p class="hint-text">Add a shortcut, then use it from any Share Sheet:</p>
614+
<div class="button-row">
615+
{#if APPLE_SAVE_SHORTCUT_URL}
616+
<a
617+
class="btn btn-secondary"
618+
href={APPLE_SAVE_SHORTCUT_URL}
619+
target="_blank"
620+
rel="noopener noreferrer">Add Save shortcut</a
621+
>
622+
{/if}
623+
{#if APPLE_SUBSCRIBE_SHORTCUT_URL}
624+
<a
625+
class="btn btn-secondary"
626+
href={APPLE_SUBSCRIBE_SHORTCUT_URL}
627+
target="_blank"
628+
rel="noopener noreferrer">Add Subscribe shortcut</a
629+
>
630+
{/if}
631+
</div>
632+
{:else}
633+
<details class="shortcut-steps">
634+
<summary>Build a Share Sheet shortcut</summary>
635+
<ol>
636+
<li>Open the <strong>Shortcuts</strong> app and create a new shortcut.</li>
637+
<li>
638+
In its settings, turn on <strong>Show in Share Sheet</strong> and set the type to
639+
<strong>URLs</strong>.
640+
</li>
641+
<li>Add <strong>Get URLs from Input</strong>, set to Shortcut Input.</li>
642+
<li>Add <strong>URL Encode</strong> (Encode) on that URL.</li>
643+
<li>
644+
Add <strong>Text</strong>: <code>{appOrigin}/save?url=</code> followed by the Encoded
645+
URL. Use <code>/subscribe?url=</code> instead for a feed shortcut.
646+
</li>
647+
<li>Add <strong>Open URLs</strong> with that text.</li>
648+
</ol>
649+
<p class="hint-text">
650+
Share any page, pick your shortcut, and it opens here and saves while you stay logged in.
651+
</p>
652+
</details>
653+
{/if}
654+
</section>
655+
551656
<section class="card">
552657
<h2>About</h2>
553658
<p>Skyreader is a reading app that helps you make sense of what you read.</p>
@@ -891,6 +996,68 @@
891996
flex-wrap: wrap;
892997
}
893998
999+
.subhead {
1000+
font-size: var(--text-md);
1001+
font-weight: var(--weight-semibold);
1002+
margin: 1.25rem 0 0.5rem;
1003+
}
1004+
1005+
.hint-text {
1006+
font-size: var(--text-sm);
1007+
color: var(--color-text-secondary);
1008+
margin: 0 0 0.5rem;
1009+
}
1010+
1011+
.bookmarklet-row {
1012+
display: flex;
1013+
flex-wrap: wrap;
1014+
gap: 0.5rem;
1015+
margin-bottom: 0.5rem;
1016+
}
1017+
1018+
.bookmarklet {
1019+
display: inline-flex;
1020+
align-items: center;
1021+
padding: 0.45rem 0.85rem;
1022+
border: 1px solid var(--color-primary, #0066cc);
1023+
border-radius: 6px;
1024+
background: var(--color-bg);
1025+
color: var(--color-primary, #0066cc);
1026+
font-size: var(--text-md);
1027+
font-weight: var(--weight-medium);
1028+
text-decoration: none;
1029+
cursor: grab;
1030+
}
1031+
1032+
.bookmarklet:active {
1033+
cursor: grabbing;
1034+
}
1035+
1036+
.shortcut-steps summary {
1037+
cursor: pointer;
1038+
font-weight: var(--weight-medium);
1039+
color: var(--color-primary);
1040+
}
1041+
1042+
.shortcut-steps ol {
1043+
margin: 0.75rem 0;
1044+
padding-left: 1.25rem;
1045+
display: flex;
1046+
flex-direction: column;
1047+
gap: 0.4rem;
1048+
font-size: var(--text-md);
1049+
color: var(--color-text);
1050+
}
1051+
1052+
.shortcut-steps code {
1053+
font-family: var(--font-mono, monospace);
1054+
font-size: 0.9em;
1055+
background: var(--color-bg-secondary);
1056+
padding: 0.05em 0.3em;
1057+
border-radius: 4px;
1058+
word-break: break-all;
1059+
}
1060+
8941061
.loading {
8951062
color: var(--color-text-secondary);
8961063
font-style: italic;

frontend/static/manifest.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@
2626
}
2727
],
2828
"categories": ["news", "productivity"],
29+
"share_target": {
30+
"action": "/save",
31+
"method": "GET",
32+
"params": {
33+
"url": "url",
34+
"text": "text",
35+
"title": "title"
36+
}
37+
},
2938
"shortcuts": [
3039
{
3140
"name": "Add Feed",

0 commit comments

Comments
 (0)