Skip to content

Commit 161a635

Browse files
committed
Add redirect bookmarklet
1 parent b911663 commit 161a635

File tree

5 files changed

+324
-65
lines changed

5 files changed

+324
-65
lines changed

bunfig.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ plugins = ["bun-plugin-tailwind"]
44

55

66

7+
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { useState, useEffect, useMemo } from "react";
2+
import { isElectron } from "../contexts/telemetry";
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogTitle,
7+
DialogDescription,
8+
} from "../ui/dialog";
9+
import { cn } from "../cn";
10+
import { ArrowRight, Github } from "lucide-react";
11+
12+
const DISMISSED_KEY = "pulldash-bookmarklet-dismissed";
13+
14+
// Generate bookmarklet code with given origin
15+
function getBookmarkletCode(origin: string): string {
16+
return `javascript:(function(){var m=location.href.match(/^https:\\/\\/github\\.com\\/([^\\/]+)\\/([^\\/]+)\\/pull\\/(\\d+)/);if(!m){alert('Open a GitHub PR first');return;}location.href='${origin}/'+m[1]+'/'+m[2]+'/pull/'+m[3];})();`;
17+
}
18+
19+
// Animation showing the flow: GitHub → Click → Pulldash
20+
function BookmarkletAnimation() {
21+
const [step, setStep] = useState(0);
22+
23+
useEffect(() => {
24+
const steps = [0, 1, 2];
25+
let currentIndex = 0;
26+
27+
const interval = setInterval(() => {
28+
currentIndex = (currentIndex + 1) % steps.length;
29+
setStep(steps[currentIndex]);
30+
}, 1200);
31+
32+
return () => clearInterval(interval);
33+
}, []);
34+
35+
return (
36+
<div className="relative h-24 flex items-center justify-center gap-3">
37+
{/* GitHub */}
38+
<div
39+
className={cn(
40+
"flex flex-col items-center gap-1.5 transition-all duration-300",
41+
step >= 0 ? "opacity-100 scale-100" : "opacity-40 scale-95"
42+
)}
43+
>
44+
<div
45+
className={cn(
46+
"w-12 h-12 rounded-lg bg-[#24292e] flex items-center justify-center transition-all duration-300",
47+
step === 0 &&
48+
"ring-2 ring-white/30 ring-offset-2 ring-offset-background"
49+
)}
50+
>
51+
<svg viewBox="0 0 24 24" className="w-7 h-7 text-white fill-current">
52+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
53+
</svg>
54+
</div>
55+
<span className="text-[10px] text-muted-foreground font-medium">
56+
GitHub PR
57+
</span>
58+
</div>
59+
60+
{/* Arrow 1 */}
61+
<ArrowRight
62+
className={cn(
63+
"w-4 h-4 transition-all duration-300",
64+
step >= 1 ? "text-foreground" : "text-muted-foreground/30"
65+
)}
66+
/>
67+
68+
{/* Bookmark Click */}
69+
<div
70+
className={cn(
71+
"flex flex-col items-center gap-1.5 transition-all duration-300",
72+
step >= 1 ? "opacity-100 scale-100" : "opacity-40 scale-95"
73+
)}
74+
>
75+
<div
76+
className={cn(
77+
"w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center transition-all duration-300",
78+
step === 1 &&
79+
"ring-2 ring-blue-400/30 ring-offset-2 ring-offset-background scale-110"
80+
)}
81+
>
82+
<svg viewBox="0 0 24 24" className="w-6 h-6 text-white fill-current">
83+
<path d="M5 4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16l-7-3.5L5 20V4z" />
84+
</svg>
85+
</div>
86+
<span className="text-[10px] text-muted-foreground font-medium">
87+
Click
88+
</span>
89+
</div>
90+
91+
{/* Arrow 2 */}
92+
<ArrowRight
93+
className={cn(
94+
"w-4 h-4 transition-all duration-300",
95+
step >= 2 ? "text-foreground" : "text-muted-foreground/30"
96+
)}
97+
/>
98+
99+
{/* Pulldash */}
100+
<div
101+
className={cn(
102+
"flex flex-col items-center gap-1.5 transition-all duration-300",
103+
step >= 2 ? "opacity-100 scale-100" : "opacity-40 scale-95"
104+
)}
105+
>
106+
<div
107+
className={cn(
108+
"w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center transition-all duration-300",
109+
step >= 2 &&
110+
"ring-2 ring-blue-400/30 ring-offset-2 ring-offset-background"
111+
)}
112+
>
113+
<img src="/logo.svg" alt="Pulldash" className="w-7 h-7" />
114+
</div>
115+
<span className="text-[10px] text-muted-foreground font-medium">
116+
Pulldash
117+
</span>
118+
</div>
119+
</div>
120+
);
121+
}
122+
123+
interface BookmarkletDialogProps {
124+
open: boolean;
125+
onOpenChange: (open: boolean) => void;
126+
}
127+
128+
export function BookmarkletDialog({
129+
open,
130+
onOpenChange,
131+
}: BookmarkletDialogProps) {
132+
// Generate the bookmarklet HTML - using dangerouslySetInnerHTML to bypass React's sanitization of javascript: URLs
133+
const bookmarkletHtml = useMemo(() => {
134+
if (typeof window === "undefined") return "";
135+
const code = getBookmarkletCode(window.location.origin);
136+
return `<a
137+
href="${code.replace(/"/g, "&quot;")}"
138+
draggable="true"
139+
style="display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; height: 56px; border-radius: 10px; font-weight: 600; background: linear-gradient(135deg, rgb(59 130 246) 0%, rgb(79 70 229) 100%); color: white; text-decoration: none; cursor: grab; user-select: none; transition: all 0.15s;"
140+
onmouseover="this.style.transform='translateY(-1px)';"
141+
onmouseout="this.style.transform='translateY(0)';"
142+
onmousedown="this.style.cursor='grabbing'; this.style.transform='scale(0.98)';"
143+
onmouseup="this.style.cursor='grab'; this.style.transform='scale(1)';"
144+
onclick="event.preventDefault(); alert('Drag this button to your bookmarks bar!')"
145+
alt="Open in Pulldash"
146+
>
147+
<span style="display: none;">Open in Pulldash</span>
148+
</a>`;
149+
}, []);
150+
151+
return (
152+
<Dialog open={open} onOpenChange={onOpenChange}>
153+
<DialogContent className="sm:max-w-md p-0 gap-0 bg-background border-border overflow-hidden">
154+
<DialogTitle className="sr-only">Redirect Bookmark</DialogTitle>
155+
<DialogDescription className="sr-only">
156+
Add a bookmark to quickly redirect from any GitHub PR to Pulldash
157+
</DialogDescription>
158+
159+
{/* Header */}
160+
<div className="p-6 pb-4">
161+
<div className="flex items-center gap-3 mb-3">
162+
<div className="w-10 h-10 rounded-lg bg-[#24292e] flex items-center justify-center">
163+
<Github className="w-5 h-5 text-white" />
164+
</div>
165+
<div>
166+
<h2 className="text-lg font-semibold text-foreground">
167+
Redirect Bookmark
168+
</h2>
169+
<p className="text-xs text-muted-foreground">
170+
One click from GitHub to Pulldash
171+
</p>
172+
</div>
173+
</div>
174+
</div>
175+
176+
{/* Animation */}
177+
<div className="px-6 pb-2">
178+
<BookmarkletAnimation />
179+
</div>
180+
181+
{/* Bookmarklet */}
182+
<div className="mx-6 mb-6 relative h-14">
183+
<div
184+
className="absolute inset-0"
185+
dangerouslySetInnerHTML={{ __html: bookmarkletHtml }}
186+
/>
187+
<span className="pointer-events-none select-none text-sm font-semibold text-white absolute inset-0 flex items-center justify-center gap-2">
188+
<svg viewBox="0 0 24 24" className="w-4 h-4 fill-current">
189+
<path d="M5 4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16l-7-3.5L5 20V4z" />
190+
</svg>
191+
Drag to Bookmarks Bar
192+
</span>
193+
</div>
194+
195+
{/* Footer */}
196+
<div className="px-6 pb-5 flex items-center justify-between border-t border-border/50 pt-4">
197+
<p className="text-xs text-muted-foreground">
198+
Can't drag? Right-click and copy link.
199+
</p>
200+
<button
201+
onClick={() => onOpenChange(false)}
202+
className="px-4 py-2 rounded-md text-xs font-medium bg-card hover:bg-muted transition-colors border border-border/50"
203+
>
204+
Done
205+
</button>
206+
</div>
207+
</DialogContent>
208+
</Dialog>
209+
);
210+
}
211+
212+
// Hook to check if bookmarklet should be shown (not in Electron, not dismissed)
213+
export function useShowBookmarklet() {
214+
const [show, setShow] = useState(false);
215+
216+
useEffect(() => {
217+
if (typeof window === "undefined") return;
218+
const dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
219+
setShow(!isElectron() && !dismissed);
220+
}, []);
221+
222+
return show;
223+
}

0 commit comments

Comments
 (0)