Merge pull request #19 from Dada1981/codex/author-card-feature-suite
feat: add Discord contact links
This commit is contained in:
commit
09c4b749f2
6 changed files with 19 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue