feat: add Discord contact links

This commit is contained in:
Daniel Heße 2026-08-16 18:58:36 +02:00
parent 040854ff1b
commit b9e9a8666e
6 changed files with 19 additions and 7 deletions

View file

@ -18,7 +18,7 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp
- Optionale serverseitige Gemini-Unterstützung für Klappentexte
- Profilabhängige SEO-Metadaten, Open Graph, strukturierte Daten, Sitemap und `robots.txt`
- Health-Endpunkte, persistente JSON-Datenhaltung und automatische Migrationsbackups
- optionale Kontaktsektion je Profil für E-Mail, Instagram und Threads
- optionale Kontaktsektion je Profil für E-Mail, Instagram, Threads und Discord
- adressierbare und teilbare Buch-/Projektansichten
- neutrale, nicht indexierbare 404-Seite ohne Hinweise auf andere Profile
@ -169,7 +169,7 @@ Die Buttons öffnen das Ziel in einem neuen Tab und werden automatisch mit den A
## Kontakt und Social Media
Jedes Profil kann unabhängig eine Kontakt-E-Mail-Adresse sowie Links zu Instagram und Threads erhalten. Der Abschnitt erscheint unterhalb des zusätzlichen Textmoduls und zeigt nur tatsächlich gepflegte Angaben. Es gibt bewusst kein serverseitiges Kontaktformular; dadurch entstehen weder Spam-Endpunkt noch zusätzliche gespeicherte Kontaktdaten.
Jedes Profil kann unabhängig eine Kontakt-E-Mail-Adresse sowie Links zu Instagram, Threads und Discord erhalten. Der Abschnitt erscheint unterhalb des zusätzlichen Textmoduls und zeigt nur tatsächlich gepflegte Angaben. Es gibt bewusst kein serverseitiges Kontaktformular; dadurch entstehen weder Spam-Endpunkt noch zusätzliche gespeicherte Kontaktdaten.
## Buchreihen, KDP-Links und Leseproben
@ -191,7 +191,7 @@ Weitere mögliche externe Verbindungen:
- `GEMINI_API_KEY`: Der optionale Admin-Assistent sendet die eingegebenen Buchinformationen serverseitig an die Google-Gemini-API.
- Kauf- und sonstige Markdown-Links: Erst ein Klick führt zur jeweiligen externen Website.
- Instagram-, Threads- und bewusst gepflegte Profilverlinkungen: Eine Verbindung entsteht erst beim Klick.
- Instagram-, Threads-, Discord- und bewusst gepflegte Profilverlinkungen: Eine Verbindung entsteht erst beim Klick.
- Externe Bild-URLs: Das System unterstützt sie weiterhin, empfohlen werden jedoch lokal hochgeladene Bilder.
Die Anwendung enthält kein Analytics- oder Tracking-System und lädt keine externen Webfonts. Sie verwendet für Besucher weder `localStorage` noch `sessionStorage`. Der Browser speichert lediglich das notwendige Admin-Sitzungscookie nach einer Anmeldung. Diese Punkte sowie das übliche Logging des Reverse Proxys/Hosters sollten passend zur tatsächlichen Installation in der Datenschutzerklärung beschrieben werden.

View file

@ -545,7 +545,7 @@ function validateProfile(profile: unknown): string | null {
return "Mindestens eine Bild-URL ist ungültig.";
}
if (value.contactEmail && (value.contactEmail.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.contactEmail))) return "Die Kontakt-E-Mail-Adresse ist ungültig.";
if (!isSafeContentUrl(value.instagramUrl) || !isSafeContentUrl(value.threadsUrl)) return "Mindestens ein Social-Media-Link ist ungültig.";
if (!isSafeContentUrl(value.instagramUrl) || !isSafeContentUrl(value.threadsUrl) || !isSafeContentUrl(value.discordUrl)) return "Mindestens ein Social-Media-Link ist ungültig.";
if (value.fontFamily && !["sans", "serif", "mono"].includes(value.fontFamily)) return "Die Schriftart ist ungültig.";
for (const project of value.projects) {
if (!project || typeof project.id !== "string" || typeof project.title !== "string" || project.title.length > 500 ||

View file

@ -666,7 +666,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<p className="text-xs text-slate-500">Diese drei Kacheln erscheinen unter der Biografie. Sie können die Kategoriename (z.B. GENRE, TONALITÄT, FOKUS) und den zugehörigen Inhalt frei anpassen.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Schlagwort 1 */}
<div className="p-3 bg-slate-950 border border-slate-800 rounded-xl space-y-2">
<div className="text-xs font-semibold text-amber-400 uppercase flex items-center gap-1">
@ -874,6 +874,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<label className="text-xs font-mono text-slate-400 uppercase">Threads-Profil</label>
<input type="url" value={profile.threadsUrl || ""} onChange={(event) => updateProfileField("threadsUrl", event.target.value)} placeholder="https://www.threads.net/@…" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400 uppercase">Discord-Einladung / Community</label>
<input type="url" value={profile.discordUrl || ""} onChange={(event) => updateProfileField("discordUrl", event.target.value)} placeholder="https://discord.gg/…" className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm" />
</div>
</div>
</div>
</div>

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import Markdown from "react-markdown";
import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types";
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon, Mail, Instagram, AtSign, Share2, FileText } from "lucide-react";
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon, Mail, Instagram, AtSign, Share2, FileText, MessageCircle } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
@ -406,7 +406,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</section>
)}
{(data.contactEmail || data.instagramUrl || data.threadsUrl) && (
{(data.contactEmail || data.instagramUrl || data.threadsUrl || data.discordUrl) && (
<section className="rounded-2xl border p-8 text-center" style={{ backgroundColor: cardBgColor, borderColor: `${secondaryColor}35` }}>
<h2 className="text-2xl font-bold text-white">Kontakt & Social Media</h2>
<p className="mt-2 text-sm text-slate-400">Weitere Neuigkeiten, Einblicke und Kontaktmöglichkeiten.</p>
@ -426,6 +426,11 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
<AtSign className="h-4 w-4" /><span>Threads</span>
</a>
)}
{safeExternalUrl(data.discordUrl || "") && (
<a href={safeExternalUrl(data.discordUrl || "")!} target="_blank" rel="noopener noreferrer" className="inline-flex items-center justify-center gap-2 rounded-xl border px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/10" style={{ borderColor: `${accentColor}60` }}>
<MessageCircle className="h-4 w-4" /><span>Discord</span>
</a>
)}
</div>
</section>
)}

View file

@ -46,6 +46,7 @@ export interface AuthorProfile {
contactEmail?: string;
instagramUrl?: string;
threadsUrl?: string;
discordUrl?: string;
// Domain & Path Routing
customDomain?: string; // e.g., "annieslone.de, annie-slone.de"

View file

@ -121,6 +121,7 @@ test("production hardening and public routing", async (t) => {
const adminData = await response.json();
adminData.erotica.contactEmail = "kontakt@example.test";
adminData.erotica.instagramUrl = "https://instagram.com/example-author";
adminData.erotica.discordUrl = "https://discord.gg/example-author";
adminData.erotica.books[0].seriesName = "Beispiel-Reihe";
adminData.erotica.books[0].seriesNumber = 1;
adminData.erotica.books[0].ebookLink = "https://amazon.example/ebook";
@ -166,6 +167,7 @@ test("production hardening and public routing", async (t) => {
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
]);
assert.equal(publicData.profile.contactEmail, "kontakt@example.test");
assert.equal(publicData.profile.discordUrl, "https://discord.gg/example-author");
assert.equal(publicData.profile.books[0].seriesName, "Beispiel-Reihe");
assert.equal(publicData.profile.books[0].seriesNumber, 1);
assert.equal(publicResponse.body.includes("Clara Finch"), false);