Linkvorschau für den ersten Link eines Beitrags
Der Server holt zum ersten Link Titel, Seiten- bzw. Kanalname und ein kleines Vorschaubild (320 px) und speichert das Bild lokal; der Browser lädt nur von uns, Besucher-IPs gehen nie an die verlinkte Seite. YouTube über oEmbed (alle Linkformen kanonisch als watch?v=<id>), alle übrigen Seiten über OpenGraph bzw. <title>. Abruf asynchron in einem Worker, eine Zeile je Link in link_preview, Auffrischen nach 30 Tagen. Der Abruf-Client lässt nur öffentliche IPs auf Port 80/443 zu, geprüft nach DNS-Auflösung und bei jeder Weiterleitung (SSRF). Bestehende Beiträge: kver link-previews. Neue Abhängigkeit golang.org/x/net, damit go 1.26. DEV bekommt ein beschreibbares Volume für die Vorschaubilder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a8f392edba
commit
26b0182391
@@ -204,6 +204,55 @@ a.button:hover, button:hover {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Linkvorschau: abgesetzte Karte unter dem Text, kleines Vorschaubild links,
|
||||
Titel und Seiten- bzw. Kanalname daneben. Rahmen wie bei den Buttons,
|
||||
weiß auch beim Card-Hover. */
|
||||
.link-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid black;
|
||||
background-color: white;
|
||||
margin: 0 0 0.5rem;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.link-preview:hover .link-preview-title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.card .link-preview img {
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
width: 160px;
|
||||
max-width: 40%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
margin: 0;
|
||||
border-right: 1px solid black;
|
||||
}
|
||||
|
||||
.link-preview-text {
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.link-preview-title,
|
||||
.link-preview-site {
|
||||
display: block;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.link-preview-title {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.link-preview-site {
|
||||
font-size: 0.9rem;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
/* --- Avatare --- */
|
||||
img.avatar {
|
||||
display: inline-block;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MouseEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import type { Entry } from "../types";
|
||||
import type { Entry, LinkPreview } from "../types";
|
||||
import { avatarUrl, formatTime, linkify } from "../format";
|
||||
import { Votes } from "./Votes";
|
||||
import { useBump } from "./Bump";
|
||||
@@ -36,6 +36,21 @@ function DeletedCard({ entry: e }: { entry: Entry }) {
|
||||
);
|
||||
}
|
||||
|
||||
// LinkPreviewCard zeigt die Vorschau des ersten Links als abgesetzte Karte:
|
||||
// kleines Bild links, Titel und Seite daneben.
|
||||
// Alles darin kommt von unserem Server; erst der Klick führt zur fremden Seite.
|
||||
function LinkPreviewCard({ preview: p }: { preview: LinkPreview }) {
|
||||
return (
|
||||
<a className="link-preview" href={p.url} target="_blank" rel="noopener noreferrer">
|
||||
{p.thumb && <img src={`/${p.thumb}`} alt="" loading="lazy" />}
|
||||
<span className="link-preview-text">
|
||||
<span className="link-preview-title">{p.title}</span>
|
||||
{p.site && <span className="link-preview-site">{p.site}</span>}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveCard({ entry: e }: { entry: Entry }) {
|
||||
const open = useOpenOnClick(e.pid);
|
||||
const bump = useBump(e);
|
||||
@@ -60,6 +75,7 @@ function LiveCard({ entry: e }: { entry: Entry }) {
|
||||
<div className="body">
|
||||
{report.form}
|
||||
<div className="content">{linkify(e.content)}</div>
|
||||
{e.preview && <LinkPreviewCard preview={e.preview} />}
|
||||
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
|
||||
<div className="interactions">
|
||||
<Link className="button reply" to={`/e/${e.pid}`}>Antworten ({e.reply_count})</Link>
|
||||
|
||||
@@ -15,11 +15,22 @@ export interface Entry {
|
||||
last_bump: number;
|
||||
username: string;
|
||||
avatar: string;
|
||||
// Vorschau des ersten Links, sobald der Server sie geholt hat.
|
||||
preview?: LinkPreview;
|
||||
// Nur in Feed und Antwortliste: jüngster Beitrag im Teilbaum, skipped =
|
||||
// Beiträge auf dem Pfad dazwischen (0 = direkte Antwort).
|
||||
latest?: { entry: Entry; skipped: number };
|
||||
}
|
||||
|
||||
// Vom Server geholte Linkvorschau; thumb liegt lokal (leer = kein Bild).
|
||||
export interface LinkPreview {
|
||||
url: string;
|
||||
kind: "youtube" | "page";
|
||||
title: string;
|
||||
site: string;
|
||||
thumb: string;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
entry: Entry;
|
||||
ancestors: Entry[];
|
||||
|
||||
@@ -119,6 +119,7 @@ E-Mail: <a>stress@bnd.wtf</a>
|
||||
<li>Optional: Hochgeladenes Bild (JPG, PNG, GIF; max. 1024x1024 Pixel)</li>
|
||||
</ul>
|
||||
<em>Sichtbarkeit:</em> Beiträge und Benutzername sind öffentlich einsehbar<br>
|
||||
<em>Linkvorschau:</em> Enthält ein Beitrag einen Link, ruft unser Server von der verlinkten Seite Titel, Seiten- bzw. Kanalname und Vorschaubild ab (bei YouTube über den öffentlichen oEmbed-Dienst von YouTube) und speichert sie auf unserem Server. Die Vorschau wird Ihnen ausschließlich von unserem Server ausgeliefert; beim Anzeigen werden keine Daten an die verlinkte Seite oder an YouTube übermittelt. Erst wenn Sie die Vorschau oder den Link anklicken, rufen Sie die fremde Seite selbst auf.<br>
|
||||
<em>Zweck:</em> Bereitstellung der Forumsfunktion<br>
|
||||
<em>Rechtsgrundlage:</em> Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)<br>
|
||||
<em>Speicherdauer:</em> Bis zur Löschung durch den Nutzer
|
||||
|
||||
Reference in New Issue
Block a user