ogthing 0.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/dev-named.mjs +24 -0
- package/bin/ogthing.mjs +387 -0
- package/index.html +17 -0
- package/package.json +72 -0
- package/portless.json +4 -0
- package/server/metadata.ts +365 -0
- package/server/ogthing-api.ts +130 -0
- package/server/types.ts +53 -0
- package/src/App.tsx +311 -0
- package/src/icons.tsx +84 -0
- package/src/main.tsx +10 -0
- package/src/previews.tsx +242 -0
- package/src/styles.css +865 -0
- package/src/tabs.tsx +374 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +16 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { MetaData, UaMode } from "../server/types";
|
|
3
|
+
import { ArrowIcon, BatIcon, CloseIcon, MoonIcon, RefreshIcon, SunIcon } from "./icons";
|
|
4
|
+
import { PreviewsTab } from "./previews";
|
|
5
|
+
import { BasicTab, ImagesTab, OpenGraphTab, RawTab, ScoreTab, TwitterTab, Tabs } from "./tabs";
|
|
6
|
+
|
|
7
|
+
const HISTORY_KEY = "ogthing_history";
|
|
8
|
+
const THEME_KEY = "ogthing_theme";
|
|
9
|
+
const HISTORY_MAX = 5;
|
|
10
|
+
|
|
11
|
+
const mainTabs = [
|
|
12
|
+
{ id: "score", label: "Score" },
|
|
13
|
+
{ id: "previews", label: "Previews" },
|
|
14
|
+
{ id: "basic", label: "Basic" },
|
|
15
|
+
{ id: "opengraph", label: "Open Graph" },
|
|
16
|
+
{ id: "twitter", label: "X" },
|
|
17
|
+
{ id: "images", label: "Images" },
|
|
18
|
+
{ id: "raw", label: "Raw" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const uaOptions: { id: UaMode; label: string }[] = [
|
|
22
|
+
{ id: "browser", label: "browser" },
|
|
23
|
+
{ id: "facebook", label: "facebookbot" },
|
|
24
|
+
{ id: "twitter", label: "twitterbot" },
|
|
25
|
+
{ id: "slack", label: "slackbot" },
|
|
26
|
+
{ id: "discord", label: "discordbot" },
|
|
27
|
+
{ id: "telegram", label: "telegrambot" },
|
|
28
|
+
{ id: "whatsapp", label: "whatsapp" },
|
|
29
|
+
{ id: "linkedin", label: "linkedinbot" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const suggestions = ["localhost:3000", "localhost:4321", "localhost:8080", "example.com"];
|
|
33
|
+
|
|
34
|
+
const addHistory = (url: string) => {
|
|
35
|
+
const history = getHistory().filter((entry) => entry !== url);
|
|
36
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify([url, ...history].slice(0, HISTORY_MAX)));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getHistory = (): string[] => {
|
|
40
|
+
try {
|
|
41
|
+
const stored = localStorage.getItem(HISTORY_KEY);
|
|
42
|
+
return stored ? (JSON.parse(stored) as string[]) : [];
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default function App() {
|
|
49
|
+
const [url, setUrl] = useState("");
|
|
50
|
+
const [metadata, setMetadata] = useState<MetaData | null>(null);
|
|
51
|
+
const [loading, setLoading] = useState(false);
|
|
52
|
+
const [error, setError] = useState("");
|
|
53
|
+
const [activeTab, setActiveTab] = useState("score");
|
|
54
|
+
const [uaMode, setUaMode] = useState<UaMode>("browser");
|
|
55
|
+
const [history, setHistory] = useState<string[]>([]);
|
|
56
|
+
const [theme, setTheme] = useState<"dark" | "light">(() => {
|
|
57
|
+
const stored = localStorage.getItem(THEME_KEY);
|
|
58
|
+
if (stored === "light" || stored === "dark") return stored;
|
|
59
|
+
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
60
|
+
});
|
|
61
|
+
const fetchToken = useRef(0);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
document.documentElement.classList.toggle("light", theme === "light");
|
|
65
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
66
|
+
}, [theme]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => setHistory(getHistory()), []);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const param = new URLSearchParams(window.location.search).get("url");
|
|
72
|
+
if (param) {
|
|
73
|
+
setUrl(param);
|
|
74
|
+
void fetchMetadata(param, "browser");
|
|
75
|
+
}
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const fetchMetadata = useCallback(async (target: string, mode: UaMode) => {
|
|
80
|
+
if (!target.trim()) return;
|
|
81
|
+
const token = ++fetchToken.current;
|
|
82
|
+
setLoading(true);
|
|
83
|
+
setError("");
|
|
84
|
+
setMetadata(null);
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(
|
|
87
|
+
`/api/metadata?url=${encodeURIComponent(target)}&full=true&ua=${mode}`,
|
|
88
|
+
);
|
|
89
|
+
const data = (await response.json()) as MetaData & { error?: string };
|
|
90
|
+
if (token !== fetchToken.current) return;
|
|
91
|
+
if (!response.ok || data.error) {
|
|
92
|
+
setError(data.error || `the request failed with ${response.status}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setMetadata(data);
|
|
96
|
+
setActiveTab("score");
|
|
97
|
+
} catch {
|
|
98
|
+
if (token === fetchToken.current) setError("could not reach the ogthing server");
|
|
99
|
+
} finally {
|
|
100
|
+
if (token === fetchToken.current) setLoading(false);
|
|
101
|
+
}
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const inspect = (value: string) => {
|
|
105
|
+
let formatted = value.trim();
|
|
106
|
+
if (!formatted) return;
|
|
107
|
+
if (!/^https?:\/\//i.test(formatted) && !/^localhost[:/]/i.test(formatted))
|
|
108
|
+
formatted = `https://${formatted}`;
|
|
109
|
+
else if (/^localhost[:/]/i.test(formatted)) formatted = `http://${formatted}`;
|
|
110
|
+
|
|
111
|
+
setUrl(formatted);
|
|
112
|
+
window.history.replaceState(null, "", `?url=${encodeURIComponent(formatted)}`);
|
|
113
|
+
void fetchMetadata(formatted, uaMode);
|
|
114
|
+
addHistory(formatted);
|
|
115
|
+
setHistory(getHistory());
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const clear = () => {
|
|
119
|
+
setUrl("");
|
|
120
|
+
setMetadata(null);
|
|
121
|
+
setError("");
|
|
122
|
+
window.history.replaceState(null, "", "/");
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const handler = (event: KeyboardEvent) => {
|
|
127
|
+
if (event.target instanceof HTMLInputElement) return;
|
|
128
|
+
if (!metadata) return;
|
|
129
|
+
const index = Number(event.key) - 1;
|
|
130
|
+
if (index >= 0 && index < mainTabs.length) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
setActiveTab(mainTabs[index].id);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
window.addEventListener("keydown", handler);
|
|
136
|
+
return () => window.removeEventListener("keydown", handler);
|
|
137
|
+
}, [metadata]);
|
|
138
|
+
|
|
139
|
+
const showLanding = !metadata && !loading;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="page">
|
|
143
|
+
<header className="topbar">
|
|
144
|
+
<div className="brand">
|
|
145
|
+
<BatIcon size={16} />
|
|
146
|
+
<span>ogthing</span>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="topbar-right">
|
|
149
|
+
<span className="local-badge">runs on your device · localhost just works</span>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className="icon-button"
|
|
153
|
+
aria-label="Toggle theme"
|
|
154
|
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
155
|
+
>
|
|
156
|
+
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</header>
|
|
160
|
+
|
|
161
|
+
<main className="content">
|
|
162
|
+
<div className={`landing ${showLanding ? "" : "collapsed"}`}>
|
|
163
|
+
<div>
|
|
164
|
+
<h1 className="hero">metadata</h1>
|
|
165
|
+
<p className="subline">
|
|
166
|
+
see how a link previews on social platforms. it runs on your machine, so localhost
|
|
167
|
+
urls work with no tunnel.
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<form
|
|
173
|
+
className="url-form"
|
|
174
|
+
onSubmit={(event) => {
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
inspect(url);
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<select
|
|
180
|
+
className="ua-select"
|
|
181
|
+
value={uaMode}
|
|
182
|
+
aria-label="User agent"
|
|
183
|
+
onChange={(event) => {
|
|
184
|
+
const mode = event.target.value as UaMode;
|
|
185
|
+
setUaMode(mode);
|
|
186
|
+
if (url && metadata) void fetchMetadata(url, mode);
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{uaOptions.map((option) => (
|
|
190
|
+
<option key={option.id} value={option.id}>
|
|
191
|
+
{option.label}
|
|
192
|
+
</option>
|
|
193
|
+
))}
|
|
194
|
+
</select>
|
|
195
|
+
<input
|
|
196
|
+
className="url-input"
|
|
197
|
+
placeholder="Enter URL"
|
|
198
|
+
value={url}
|
|
199
|
+
autoComplete="off"
|
|
200
|
+
spellCheck={false}
|
|
201
|
+
onChange={(event) => setUrl(event.target.value)}
|
|
202
|
+
/>
|
|
203
|
+
<div className="url-actions">
|
|
204
|
+
{loading ? (
|
|
205
|
+
<span className="spinner" />
|
|
206
|
+
) : (
|
|
207
|
+
<>
|
|
208
|
+
{(url || metadata) && (
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
className="icon-button"
|
|
212
|
+
onMouseDown={clear}
|
|
213
|
+
aria-label="Clear"
|
|
214
|
+
>
|
|
215
|
+
<CloseIcon />
|
|
216
|
+
</button>
|
|
217
|
+
)}
|
|
218
|
+
{metadata && (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
className="icon-button"
|
|
222
|
+
onMouseDown={() => void fetchMetadata(url, uaMode)}
|
|
223
|
+
aria-label="Refresh"
|
|
224
|
+
>
|
|
225
|
+
<RefreshIcon />
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
228
|
+
{url && !metadata && (
|
|
229
|
+
<button type="submit" className="icon-button accent" aria-label="Inspect">
|
|
230
|
+
<ArrowIcon />
|
|
231
|
+
</button>
|
|
232
|
+
)}
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
</form>
|
|
237
|
+
|
|
238
|
+
{error && <div className="error">{error}</div>}
|
|
239
|
+
{loading && !metadata && <div className="status-line">fetching…</div>}
|
|
240
|
+
|
|
241
|
+
<div className={`landing ${showLanding ? "" : "collapsed"}`}>
|
|
242
|
+
<div>
|
|
243
|
+
<div className="try-row">
|
|
244
|
+
<div className="section-label">Try these</div>
|
|
245
|
+
<div className="chip-row">
|
|
246
|
+
{suggestions.map((suggestion) => (
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
key={suggestion}
|
|
250
|
+
className="chip"
|
|
251
|
+
onMouseDown={() => inspect(suggestion)}
|
|
252
|
+
>
|
|
253
|
+
↘ {suggestion}
|
|
254
|
+
</button>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
{history.length > 0 && (
|
|
259
|
+
<div className="try-row">
|
|
260
|
+
<div className="section-label-row">
|
|
261
|
+
<span className="section-label">Recent</span>
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
className="link-button"
|
|
265
|
+
onMouseDown={() => {
|
|
266
|
+
localStorage.removeItem(HISTORY_KEY);
|
|
267
|
+
setHistory([]);
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
clear
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="chip-row">
|
|
274
|
+
{history.map((entry) => (
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
key={entry}
|
|
278
|
+
className="chip"
|
|
279
|
+
onMouseDown={() => inspect(entry)}
|
|
280
|
+
>
|
|
281
|
+
↘ {entry}
|
|
282
|
+
</button>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{metadata && (
|
|
291
|
+
<div className="results">
|
|
292
|
+
<Tabs tabs={mainTabs} active={activeTab} onChange={setActiveTab} />
|
|
293
|
+
<div className="tab-body">
|
|
294
|
+
{mainTabs.map((tab) => (
|
|
295
|
+
<div key={tab.id} className={activeTab === tab.id ? "" : "hidden"}>
|
|
296
|
+
{tab.id === "score" && <ScoreTab metadata={metadata} />}
|
|
297
|
+
{tab.id === "previews" && <PreviewsTab metadata={metadata} url={url} />}
|
|
298
|
+
{tab.id === "basic" && <BasicTab metadata={metadata} url={url} />}
|
|
299
|
+
{tab.id === "opengraph" && <OpenGraphTab metadata={metadata} />}
|
|
300
|
+
{tab.id === "twitter" && <TwitterTab metadata={metadata} />}
|
|
301
|
+
{tab.id === "images" && <ImagesTab metadata={metadata} />}
|
|
302
|
+
{tab.id === "raw" && <RawTab metadata={metadata} />}
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
</main>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
package/src/icons.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
interface IconProps {
|
|
2
|
+
size?: number;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const base = (size?: number) => ({
|
|
6
|
+
width: size ?? 14,
|
|
7
|
+
height: size ?? 14,
|
|
8
|
+
viewBox: "0 0 24 24",
|
|
9
|
+
fill: "none",
|
|
10
|
+
stroke: "currentColor",
|
|
11
|
+
strokeWidth: 2,
|
|
12
|
+
strokeLinecap: "round" as const,
|
|
13
|
+
strokeLinejoin: "round" as const,
|
|
14
|
+
"aria-hidden": true,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const CloseIcon = ({ size }: IconProps) => (
|
|
18
|
+
<svg {...base(size)}>
|
|
19
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const RefreshIcon = ({ size }: IconProps) => (
|
|
24
|
+
<svg {...base(size)}>
|
|
25
|
+
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
|
26
|
+
<path d="M21 3v6h-6" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const ArrowIcon = ({ size }: IconProps) => (
|
|
31
|
+
<svg {...base(size)}>
|
|
32
|
+
<path d="M5 12h14M12 5l7 7-7 7" />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const CopyIcon = ({ size }: IconProps) => (
|
|
37
|
+
<svg {...base(size)}>
|
|
38
|
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
|
39
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
40
|
+
</svg>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const CheckIcon = ({ size }: IconProps) => (
|
|
44
|
+
<svg {...base(size)}>
|
|
45
|
+
<path d="m20 6-11 11-5-5" />
|
|
46
|
+
</svg>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export const ExternalIcon = ({ size }: IconProps) => (
|
|
50
|
+
<svg {...base(size)}>
|
|
51
|
+
<path d="M15 3h6v6" />
|
|
52
|
+
<path d="M10 14 21 3" />
|
|
53
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export const SunIcon = ({ size }: IconProps) => (
|
|
58
|
+
<svg {...base(size)}>
|
|
59
|
+
<circle cx="12" cy="12" r="4" />
|
|
60
|
+
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
|
61
|
+
</svg>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export const MoonIcon = ({ size }: IconProps) => (
|
|
65
|
+
<svg {...base(size)}>
|
|
66
|
+
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
|
67
|
+
</svg>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
export const ChevronIcon = ({ size }: IconProps) => (
|
|
71
|
+
<svg {...base(size)}>
|
|
72
|
+
<path d="m6 9 6 6 6-6" />
|
|
73
|
+
</svg>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
export const BatIcon = ({ size }: IconProps) => (
|
|
77
|
+
<svg {...base(size)} viewBox="0 0 24 24">
|
|
78
|
+
<path
|
|
79
|
+
d="M12 3c.5 2 1.8 3.4 4 4-1 1.2-1.3 2.4-1 4 1.5-.8 3.4-.7 5 .5-1.6.4-2.6 1.3-3 3-1.2-1-2.5-1.3-4-.8L12 16l-1-2.3c-1.5-.5-2.8-.2-4 .8-.4-1.7-1.4-2.6-3-3 1.6-1.2 3.5-1.3 5-.5.3-1.6 0-2.8-1-4 2.2-.6 3.5-2 4-4Z"
|
|
80
|
+
fill="currentColor"
|
|
81
|
+
stroke="none"
|
|
82
|
+
/>
|
|
83
|
+
</svg>
|
|
84
|
+
);
|
package/src/main.tsx
ADDED
package/src/previews.tsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { MetaData } from "../server/types";
|
|
3
|
+
import { ExternalIcon } from "./icons";
|
|
4
|
+
import { Tabs } from "./tabs";
|
|
5
|
+
|
|
6
|
+
interface PreviewProps {
|
|
7
|
+
metadata: MetaData;
|
|
8
|
+
url: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const hostOf = (url: string) => {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(url).host;
|
|
14
|
+
} catch {
|
|
15
|
+
return url.replace(/^https?:\/\//, "").split("/")[0];
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const nocache = (url: string) =>
|
|
20
|
+
url ? `${url}${url.includes("?") ? "&" : "?"}_t=${Date.now()}` : url;
|
|
21
|
+
|
|
22
|
+
const hideOnError = (event: React.SyntheticEvent<HTMLImageElement>) => {
|
|
23
|
+
event.currentTarget.style.display = "none";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const TelegramPreview = ({ metadata, url }: PreviewProps) => {
|
|
27
|
+
const title = metadata.ogTitle || metadata.title || url;
|
|
28
|
+
const description = metadata.ogDescription || metadata.description || "";
|
|
29
|
+
const image = metadata.ogImage || metadata.twitterImage || "";
|
|
30
|
+
const truncated = description.length > 120 ? `${description.slice(0, 120)}…` : description;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="pv pv-telegram">
|
|
34
|
+
<div className="pv-telegram-url">{url}</div>
|
|
35
|
+
<div className="pv-telegram-body">
|
|
36
|
+
<div className="pv-title-sm">{title}</div>
|
|
37
|
+
<div className="pv-desc-xs">{truncated}</div>
|
|
38
|
+
</div>
|
|
39
|
+
{image && (
|
|
40
|
+
<img src={nocache(image)} alt={title} className="pv-img-cover tall" onError={hideOnError} />
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const DiscordPreview = ({ metadata, url }: PreviewProps) => {
|
|
47
|
+
const title = metadata.ogTitle || metadata.title || url;
|
|
48
|
+
const description = metadata.ogDescription || metadata.description || "";
|
|
49
|
+
const image = metadata.ogImage || metadata.twitterImage || "";
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="pv pv-discord">
|
|
53
|
+
<div className="pv-accent">
|
|
54
|
+
{image && (
|
|
55
|
+
<img src={nocache(image)} alt={title} className="pv-img-rounded" onError={hideOnError} />
|
|
56
|
+
)}
|
|
57
|
+
<div className="pv-title">{title}</div>
|
|
58
|
+
<div className="pv-desc-clamp">{description}</div>
|
|
59
|
+
<div className="pv-host">
|
|
60
|
+
{hostOf(url)}
|
|
61
|
+
<ExternalIcon size={12} />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const SlackPreview = ({ metadata, url }: PreviewProps) => {
|
|
69
|
+
const title = metadata.ogTitle || metadata.title || url;
|
|
70
|
+
const description = metadata.ogDescription || metadata.description || "";
|
|
71
|
+
const image = metadata.ogImage || metadata.twitterImage || "";
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="pv pv-slack">
|
|
75
|
+
<div className="pv-accent slack">
|
|
76
|
+
<div className="pv-title-sm">{title}</div>
|
|
77
|
+
<div className="pv-desc-clamp xs">{description}</div>
|
|
78
|
+
{image && (
|
|
79
|
+
<img src={nocache(image)} alt={title} className="pv-img-inline" onError={hideOnError} />
|
|
80
|
+
)}
|
|
81
|
+
<div className="pv-host">
|
|
82
|
+
{hostOf(url)}
|
|
83
|
+
<ExternalIcon size={12} />
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const WhatsAppPreview = ({ metadata, url }: PreviewProps) => {
|
|
91
|
+
const title = metadata.ogTitle || metadata.title || url;
|
|
92
|
+
const description = metadata.ogDescription || metadata.description || "";
|
|
93
|
+
const image = metadata.ogImage || metadata.twitterImage || "";
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="pv pv-whatsapp">
|
|
97
|
+
<div className="pv-bordered">
|
|
98
|
+
{image && (
|
|
99
|
+
<img
|
|
100
|
+
src={nocache(image)}
|
|
101
|
+
alt={title}
|
|
102
|
+
className="pv-img-cover medium"
|
|
103
|
+
onError={hideOnError}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
<div className="pv-padded">
|
|
107
|
+
<div className="pv-title clamp1">{title}</div>
|
|
108
|
+
<div className="pv-desc-xs clamp2">{description}</div>
|
|
109
|
+
<div className="pv-host-dim">{hostOf(url)}</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const FacebookPreview = ({ metadata, url }: PreviewProps) => {
|
|
117
|
+
const title = metadata.ogTitle || metadata.title || url;
|
|
118
|
+
const description = metadata.ogDescription || metadata.description || "";
|
|
119
|
+
const image = metadata.ogImage || metadata.twitterImage || "";
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="pv pv-facebook">
|
|
123
|
+
<div className="pv-bordered shadowed">
|
|
124
|
+
{image && (
|
|
125
|
+
<img
|
|
126
|
+
src={nocache(image)}
|
|
127
|
+
alt={title}
|
|
128
|
+
className="pv-img-cover medium"
|
|
129
|
+
onError={hideOnError}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
<div className="pv-padded">
|
|
133
|
+
<div className="pv-host-caps">{hostOf(url)}</div>
|
|
134
|
+
<div className="pv-title-sm clamp1">{title}</div>
|
|
135
|
+
<div className="pv-desc-xs clamp2">{description}</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const LinkedInPreview = ({ metadata, url }: PreviewProps) => {
|
|
143
|
+
const title = metadata.ogTitle || metadata.title || url;
|
|
144
|
+
const description = metadata.ogDescription || metadata.description || "";
|
|
145
|
+
const image = metadata.ogImage || metadata.twitterImage || "";
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className="pv pv-linkedin">
|
|
149
|
+
<div className="pv-bordered">
|
|
150
|
+
{image && (
|
|
151
|
+
<img
|
|
152
|
+
src={nocache(image)}
|
|
153
|
+
alt={title}
|
|
154
|
+
className="pv-img-cover medium"
|
|
155
|
+
onError={hideOnError}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
<div className="pv-padded">
|
|
159
|
+
<div className="pv-host-caps">{hostOf(url)}</div>
|
|
160
|
+
<div className="pv-title-sm clamp1">{title}</div>
|
|
161
|
+
<div className="pv-desc-xs clamp2">{description}</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const XPreview = ({ metadata, url }: PreviewProps) => {
|
|
169
|
+
const title = metadata.twitterTitle || metadata.ogTitle || metadata.title || url;
|
|
170
|
+
const description =
|
|
171
|
+
metadata.twitterDescription || metadata.ogDescription || metadata.description || "";
|
|
172
|
+
const image = metadata.twitterImage || metadata.ogImage || "";
|
|
173
|
+
const largeCard =
|
|
174
|
+
metadata.twitterCard !== "summary" &&
|
|
175
|
+
(metadata.twitterCard === "summary_large_image" || !!image);
|
|
176
|
+
|
|
177
|
+
if (!largeCard) {
|
|
178
|
+
return (
|
|
179
|
+
<div className="pv-x small">
|
|
180
|
+
{image ? (
|
|
181
|
+
<img src={nocache(image)} alt={title} onError={hideOnError} />
|
|
182
|
+
) : (
|
|
183
|
+
<div className="x-fallback">no image</div>
|
|
184
|
+
)}
|
|
185
|
+
<div className="x-body">
|
|
186
|
+
<div className="x-host">{hostOf(url)}</div>
|
|
187
|
+
<div className="x-title clamp1">{title}</div>
|
|
188
|
+
<div className="x-desc clamp2">{description}</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className="pv-x large">
|
|
196
|
+
{image && (
|
|
197
|
+
<div className="x-hero">
|
|
198
|
+
<img src={nocache(image)} alt={title} onError={hideOnError} />
|
|
199
|
+
<span className="x-overlay clamp1">{title}</span>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
{!image && (
|
|
203
|
+
<div className="x-body padded">
|
|
204
|
+
<div className="x-host">{hostOf(url)}</div>
|
|
205
|
+
<div className="x-title">{title}</div>
|
|
206
|
+
<div className="x-desc clamp2">{description}</div>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const platforms = [
|
|
214
|
+
{ id: "telegram", label: "Telegram", Component: TelegramPreview },
|
|
215
|
+
{ id: "discord", label: "Discord", Component: DiscordPreview },
|
|
216
|
+
{ id: "slack", label: "Slack", Component: SlackPreview },
|
|
217
|
+
{ id: "twitter", label: "X", Component: XPreview },
|
|
218
|
+
{ id: "facebook", label: "Facebook", Component: FacebookPreview },
|
|
219
|
+
{ id: "linkedin", label: "LinkedIn", Component: LinkedInPreview },
|
|
220
|
+
{ id: "whatsapp", label: "WhatsApp", Component: WhatsAppPreview },
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
export function PreviewsTab({ metadata, url }: PreviewProps) {
|
|
224
|
+
const [platform, setPlatform] = useState("telegram");
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div>
|
|
228
|
+
<Tabs
|
|
229
|
+
tabs={platforms.map((p) => ({ id: p.id, label: p.label }))}
|
|
230
|
+
active={platform}
|
|
231
|
+
onChange={setPlatform}
|
|
232
|
+
/>
|
|
233
|
+
<div className="preview-stage">
|
|
234
|
+
{platforms.map(({ id, Component }) => (
|
|
235
|
+
<div key={id} className={platform === id ? "" : "hidden"}>
|
|
236
|
+
<Component metadata={metadata} url={url} />
|
|
237
|
+
</div>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|