v3: add backlinks between dumps (via either dump url or dump target url mentioned through dump description and dump comments)
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
|
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
|
||||||
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
|
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
|
||||||
|
import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,6 +14,7 @@ interface Migration {
|
|||||||
const MIGRATIONS: Migration[] = [
|
const MIGRATIONS: Migration[] = [
|
||||||
{ name: "0001_comment_likes", up: up0001CommentLikes },
|
{ name: "0001_comment_likes", up: up0001CommentLikes },
|
||||||
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
|
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
|
||||||
|
{ name: "0003_dump_backlinks", up: up0003DumpBacklinks },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
export function runMigrations(db: DatabaseSync): void {
|
||||||
|
|||||||
26
api/db/migrations/0003_dump_backlinks.ts
Normal file
26
api/db/migrations/0003_dump_backlinks.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||||
|
// with these) or an existing one that predates them.
|
||||||
|
export function up(db: DatabaseSync): void {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks (
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
source_id TEXT NOT NULL,
|
||||||
|
from_dump_id TEXT NOT NULL,
|
||||||
|
to_dump_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (source_type, source_id, to_dump_id),
|
||||||
|
FOREIGN KEY (from_dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (to_dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
||||||
|
);`);
|
||||||
|
|
||||||
|
db.exec(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dump_backlinks_to ON dump_backlinks(to_dump_id);`,
|
||||||
|
);
|
||||||
|
db.exec(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dump_backlinks_from ON dump_backlinks(from_dump_id);`,
|
||||||
|
);
|
||||||
|
db.exec(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dumps_url ON dumps(url);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,13 +87,27 @@ CREATE TABLE comment_likes (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE dump_backlinks (
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
source_id TEXT NOT NULL,
|
||||||
|
from_dump_id TEXT NOT NULL,
|
||||||
|
to_dump_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (source_type, source_id, to_dump_id),
|
||||||
|
FOREIGN KEY (from_dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (to_dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_dumps_user ON dumps(user_id);
|
CREATE INDEX idx_dumps_user ON dumps(user_id);
|
||||||
|
CREATE INDEX idx_dumps_url ON dumps(url);
|
||||||
CREATE INDEX idx_votes_user ON votes(user_id);
|
CREATE INDEX idx_votes_user ON votes(user_id);
|
||||||
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
||||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
||||||
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
||||||
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
||||||
CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
|
CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
|
||||||
|
CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id);
|
||||||
|
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id);
|
||||||
|
|
||||||
CREATE TABLE follows (
|
CREATE TABLE follows (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
updateDump,
|
updateDump,
|
||||||
} from "../services/dump-service.ts";
|
} from "../services/dump-service.ts";
|
||||||
import { getDumpVoters } from "../services/vote-service.ts";
|
import { getDumpVoters } from "../services/vote-service.ts";
|
||||||
|
import { getRelatedDumps } from "../services/backlink-service.ts";
|
||||||
|
|
||||||
const router = new Router({ prefix: "/api/dumps" });
|
const router = new Router({ prefix: "/api/dumps" });
|
||||||
|
|
||||||
@@ -104,6 +105,14 @@ router.get("/:dumpId/voters", async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/:dumpId/related", async (ctx) => {
|
||||||
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
|
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||||
|
const related = getRelatedDumps(dump.id, requestingUserId);
|
||||||
|
const responseBody: APIResponse<Dump[]> = { success: true, data: related };
|
||||||
|
ctx.response.body = responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/", async (ctx) => {
|
router.get("/", async (ctx) => {
|
||||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
|
|||||||
116
api/services/backlink-service.ts
Normal file
116
api/services/backlink-service.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { type Dump } from "../model/interfaces.ts";
|
||||||
|
import { db, dumpRowToApi, isDumpRow } from "../model/db.ts";
|
||||||
|
import { UUID_RE } from "../lib/slugify.ts";
|
||||||
|
|
||||||
|
// Mirrors dump-service SELECT_COLS — kept local to avoid a circular import
|
||||||
|
// (dump-service.ts must import relinkBacklinks from this file).
|
||||||
|
const SELECT_COLS =
|
||||||
|
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime," +
|
||||||
|
" (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count";
|
||||||
|
|
||||||
|
// Matches http(s) URLs in free text — stops at whitespace or markdown-link
|
||||||
|
// delimiters (so `[text](url)` and `<url>` don't swallow the closing char).
|
||||||
|
const URL_RE = /https?:\/\/[^\s)\]<>"']+/g;
|
||||||
|
|
||||||
|
// A link to another dump's page on this same site (e.g. pasted from the
|
||||||
|
// address bar) — host-agnostic, since the configured public URL can differ
|
||||||
|
// between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`,
|
||||||
|
// ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links
|
||||||
|
// still resolve to the same target dump).
|
||||||
|
const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/;
|
||||||
|
|
||||||
|
function canonicalize(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a same-site `/dumps/{slug-or-id}` path segment to a dump id. */
|
||||||
|
function resolveDumpPathId(idOrSlug: string): string | null {
|
||||||
|
const row = UUID_RE.test(idOrSlug)
|
||||||
|
? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug)
|
||||||
|
: db.prepare(`SELECT id FROM dumps WHERE slug = ?;`).get(idOrSlug);
|
||||||
|
return (row as { id: string } | undefined)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-derive the backlinks contributed by one text source (a dump's own
|
||||||
|
* `comment` field, or a single comment's `body`) and persist them. Always
|
||||||
|
* deletes the source's previous contribution first, so editing/clearing the
|
||||||
|
* text correctly removes stale links — unlike `linkAttachments`/`notifyMentions`,
|
||||||
|
* which are purely additive.
|
||||||
|
*/
|
||||||
|
export function relinkBacklinks(
|
||||||
|
sourceType: "dump_comment" | "comment",
|
||||||
|
sourceId: string,
|
||||||
|
fromDumpId: string,
|
||||||
|
text: string,
|
||||||
|
): void {
|
||||||
|
const targetIds = new Set<string>();
|
||||||
|
const externalUrls = new Set<string>();
|
||||||
|
|
||||||
|
for (const match of text.matchAll(URL_RE)) {
|
||||||
|
const canonical = canonicalize(match[0]);
|
||||||
|
if (!canonical) continue;
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(canonical);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pathMatch = DUMP_PATH_RE.exec(parsed.pathname);
|
||||||
|
if (pathMatch) {
|
||||||
|
const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1]));
|
||||||
|
if (resolved) targetIds.add(resolved);
|
||||||
|
} else {
|
||||||
|
externalUrls.add(canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalUrls.size > 0) {
|
||||||
|
const placeholders = [...externalUrls].map(() => "?").join(", ");
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id FROM dumps WHERE url IN (${placeholders});`,
|
||||||
|
).all(...externalUrls) as { id: string }[];
|
||||||
|
for (const row of rows) targetIds.add(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIds.delete(fromDumpId); // no self-links
|
||||||
|
|
||||||
|
db.exec("BEGIN;");
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
|
||||||
|
).run(sourceType, sourceId);
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
for (const toDumpId of targetIds) {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?);`,
|
||||||
|
).run(sourceType, sourceId, fromDumpId, toDumpId, createdAt);
|
||||||
|
}
|
||||||
|
db.exec("COMMIT;");
|
||||||
|
} catch (err) {
|
||||||
|
db.exec("ROLLBACK;");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */
|
||||||
|
export function getRelatedDumps(
|
||||||
|
dumpId: string,
|
||||||
|
requestingUserId?: string,
|
||||||
|
): Dump[] {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS} FROM dumps WHERE id IN (
|
||||||
|
SELECT to_dump_id FROM dump_backlinks WHERE from_dump_id = ?
|
||||||
|
UNION
|
||||||
|
SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ?
|
||||||
|
) AND (is_private = 0 OR user_id = ?)
|
||||||
|
ORDER BY created_at DESC LIMIT 20;`,
|
||||||
|
).all(dumpId, dumpId, requestingUserId ?? "");
|
||||||
|
|
||||||
|
return rows.filter(isDumpRow).map(dumpRowToApi);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
notifyMentions,
|
notifyMentions,
|
||||||
} from "./notification-service.ts";
|
} from "./notification-service.ts";
|
||||||
import { linkAttachments } from "./attachment-service.ts";
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
|
import { relinkBacklinks } from "./backlink-service.ts";
|
||||||
|
|
||||||
const SELECT_COLS =
|
const SELECT_COLS =
|
||||||
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count,
|
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count,
|
||||||
@@ -82,6 +83,7 @@ export function createComment(
|
|||||||
notifyDumpOwnerNewComment(userId, id, dumpId);
|
notifyDumpOwnerNewComment(userId, id, dumpId);
|
||||||
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
|
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
|
||||||
linkAttachments(body, id);
|
linkAttachments(body, id);
|
||||||
|
relinkBacklinks("comment", id, dumpId, body);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +132,7 @@ export function updateComment(
|
|||||||
row.dump_id,
|
row.dump_id,
|
||||||
);
|
);
|
||||||
linkAttachments(body, commentId);
|
linkAttachments(body, commentId);
|
||||||
|
relinkBacklinks("comment", commentId, row.dump_id, body);
|
||||||
return {
|
return {
|
||||||
comment: fetchComment(commentId),
|
comment: fetchComment(commentId),
|
||||||
dumpId: row.dump_id,
|
dumpId: row.dump_id,
|
||||||
@@ -159,5 +162,6 @@ export function deleteComment(
|
|||||||
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
|
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
|
||||||
commentId,
|
commentId,
|
||||||
);
|
);
|
||||||
|
relinkBacklinks("comment", commentId, row.dump_id, "");
|
||||||
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
|
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
THUMBNAILS_DIR,
|
THUMBNAILS_DIR,
|
||||||
} from "../config.ts";
|
} from "../config.ts";
|
||||||
import { linkAttachments } from "./attachment-service.ts";
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
|
import { relinkBacklinks } from "./backlink-service.ts";
|
||||||
|
|
||||||
function isAllowedMime(mime: string): boolean {
|
function isAllowedMime(mime: string): boolean {
|
||||||
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
||||||
@@ -102,6 +103,7 @@ export async function createUrlDump(
|
|||||||
notifyMentions(userId, request.comment, "dump", dumpId, title);
|
notifyMentions(userId, request.comment, "dump", dumpId, title);
|
||||||
linkAttachments(request.comment, dumpId);
|
linkAttachments(request.comment, dumpId);
|
||||||
}
|
}
|
||||||
|
relinkBacklinks("dump_comment", dumpId, dumpId, request.comment ?? "");
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ export async function createFileDump(
|
|||||||
notifyMentions(userId, comment, "dump", dumpId, finalTitle);
|
notifyMentions(userId, comment, "dump", dumpId, finalTitle);
|
||||||
linkAttachments(comment, dumpId);
|
linkAttachments(comment, dumpId);
|
||||||
}
|
}
|
||||||
|
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +329,7 @@ export async function updateDump(
|
|||||||
);
|
);
|
||||||
linkAttachments(updatedDump.comment, dumpId);
|
linkAttachments(updatedDump.comment, dumpId);
|
||||||
}
|
}
|
||||||
|
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +397,7 @@ export async function updateDump(
|
|||||||
);
|
);
|
||||||
linkAttachments(updatedDump.comment, dumpId);
|
linkAttachments(updatedDump.comment, dumpId);
|
||||||
}
|
}
|
||||||
|
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +462,7 @@ export async function replaceFileDump(
|
|||||||
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
|
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
|
||||||
linkAttachments(comment, dumpId);
|
linkAttachments(comment, dumpId);
|
||||||
}
|
}
|
||||||
|
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
|
||||||
return {
|
return {
|
||||||
...dump,
|
...dump,
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
|
|||||||
15
src/App.css
15
src/App.css
@@ -3509,6 +3509,21 @@ body.has-player .fab-new {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.related-section {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding-top: 1.75rem;
|
||||||
|
border-top: 2px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-section-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-list {
|
.comment-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { Avatar } from "../components/Avatar.tsx";
|
|||||||
import RichContentCard from "../components/RichContentCard.tsx";
|
import RichContentCard from "../components/RichContentCard.tsx";
|
||||||
import FilePreview from "../components/FilePreview.tsx";
|
import FilePreview from "../components/FilePreview.tsx";
|
||||||
import { VoteButton } from "../components/VoteButton.tsx";
|
import { VoteButton } from "../components/VoteButton.tsx";
|
||||||
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { PageError } from "../components/PageError.tsx";
|
import { PageError } from "../components/PageError.tsx";
|
||||||
import { Markdown } from "../components/Markdown.tsx";
|
import { Markdown } from "../components/Markdown.tsx";
|
||||||
@@ -54,6 +55,7 @@ export function Dump() {
|
|||||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||||
|
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
|
||||||
|
|
||||||
const [titleEditing, setTitleEditing] = useState(false);
|
const [titleEditing, setTitleEditing] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState("");
|
const [titleDraft, setTitleDraft] = useState("");
|
||||||
@@ -145,6 +147,24 @@ export function Dump() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [selectedDump, token]);
|
}, [selectedDump, token]);
|
||||||
|
|
||||||
|
// Fetch related dumps (backlinks) when dump loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDump) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch(`${API_URL}/api/dumps/${selectedDump}/related`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (body.success) {
|
||||||
|
setRelatedDumps((body.data as RawDump[]).map(deserializeDump));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [selectedDump, token]);
|
||||||
|
|
||||||
// Scroll to and highlight a comment when navigating to #comment-{id}
|
// Scroll to and highlight a comment when navigating to #comment-{id}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!location.hash.startsWith("#comment-")) return;
|
if (!location.hash.startsWith("#comment-")) return;
|
||||||
@@ -440,6 +460,28 @@ export function Dump() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Related (backlinks) */}
|
||||||
|
{relatedDumps.length > 0 && (
|
||||||
|
<section className="related-section">
|
||||||
|
<h2 className="related-section-title">
|
||||||
|
<Trans>Related</Trans>
|
||||||
|
</h2>
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{relatedDumps.map((related) => (
|
||||||
|
<DumpCard
|
||||||
|
key={related.id}
|
||||||
|
dump={related}
|
||||||
|
voteCount={voteCounts[related.id] ?? related.voteCount}
|
||||||
|
voted={myVotes.has(related.id)}
|
||||||
|
canVote={!!user}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
<CommentThread
|
<CommentThread
|
||||||
dumpId={dump.id}
|
dumpId={dump.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user