feat: add Discord contact links #19

Merged
Dada1981 merged 1 commit from codex/author-card-feature-suite into main 2026-08-16 16:59:21 +00:00
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 - Optionale serverseitige Gemini-Unterstützung für Klappentexte
- Profilabhängige SEO-Metadaten, Open Graph, strukturierte Daten, Sitemap und `robots.txt` - Profilabhängige SEO-Metadaten, Open Graph, strukturierte Daten, Sitemap und `robots.txt`
- Health-Endpunkte, persistente JSON-Datenhaltung und automatische Migrationsbackups - 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 - adressierbare und teilbare Buch-/Projektansichten
- neutrale, nicht indexierbare 404-Seite ohne Hinweise auf andere Profile - 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 ## 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 ## 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. - `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. - 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. - 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. 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."; 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 (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."; if (value.fontFamily && !["sans", "serif", "mono"].includes(value.fontFamily)) return "Die Schriftart ist ungültig.";
for (const project of value.projects) { for (const project of value.projects) {
if (!project || typeof project.id !== "string" || typeof project.title !== "string" || project.title.length > 500 || 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> <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>
<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 */} {/* Schlagwort 1 */}
<div className="p-3 bg-slate-950 border border-slate-800 rounded-xl space-y-2"> <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"> <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> <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" /> <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>
<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> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types"; 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 SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal"; import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia"; import ProjectDetailMedia from "./ProjectDetailMedia";
@ -406,7 +406,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</section> </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` }}> <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> <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> <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> <AtSign className="h-4 w-4" /><span>Threads</span>
</a> </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> </div>
</section> </section>
)} )}

View file

@ -46,6 +46,7 @@ export interface AuthorProfile {
contactEmail?: string; contactEmail?: string;
instagramUrl?: string; instagramUrl?: string;
threadsUrl?: string; threadsUrl?: string;
discordUrl?: string;
// Domain & Path Routing // Domain & Path Routing
customDomain?: string; // e.g., "annieslone.de, annie-slone.de" 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(); const adminData = await response.json();
adminData.erotica.contactEmail = "kontakt@example.test"; adminData.erotica.contactEmail = "kontakt@example.test";
adminData.erotica.instagramUrl = "https://instagram.com/example-author"; 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].seriesName = "Beispiel-Reihe";
adminData.erotica.books[0].seriesNumber = 1; adminData.erotica.books[0].seriesNumber = 1;
adminData.erotica.books[0].ebookLink = "https://amazon.example/ebook"; 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/" }, { label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
]); ]);
assert.equal(publicData.profile.contactEmail, "kontakt@example.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].seriesName, "Beispiel-Reihe");
assert.equal(publicData.profile.books[0].seriesNumber, 1); assert.equal(publicData.profile.books[0].seriesNumber, 1);
assert.equal(publicResponse.body.includes("Clara Finch"), false); assert.equal(publicResponse.body.includes("Clara Finch"), false);