fix(web): remove redundant prop-types and unblock lint pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Failing after 2m24s
Build & Deploy / 🏗️ Build (push) Failing after 3m40s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Failing after 2m24s
Build & Deploy / 🏗️ Build (push) Failing after 3m40s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
This commit is contained in:
144
apps/web/src/payload/components/ColorPicker/index.tsx
Normal file
144
apps/web/src/payload/components/ColorPicker/index.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useField } from "@payloadcms/ui";
|
||||
|
||||
const PREDEFINED_COLORS = [
|
||||
{ label: "Slate (Default)", value: "slate", color: "#64748b" },
|
||||
{ label: "Primary Blue", value: "blue", color: "#2563eb" },
|
||||
{ label: "Success Green", value: "green", color: "#10b981" },
|
||||
{ label: "Danger Red", value: "red", color: "#ef4444" },
|
||||
{ label: "Mintel Neon", value: "neon", color: "#d9f99d" },
|
||||
{ label: "Dark Navy", value: "navy", color: "#0f172a" },
|
||||
{ label: "Brand Slate", value: "brand-slate", color: "#334155" },
|
||||
{ label: "Emerald", value: "emerald", color: "#059669" },
|
||||
{ label: "Purple", value: "purple", color: "#8b5cf6" },
|
||||
];
|
||||
|
||||
export default function ColorPickerField({ path }: { path: string }) {
|
||||
const { value, setValue } = useField<string>({ path });
|
||||
|
||||
const handleColorClick = (colorValue: string) => {
|
||||
setValue(colorValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="field-type"
|
||||
style={{ marginBottom: "1.5rem", fontFamily: "inherit" }}
|
||||
>
|
||||
<label
|
||||
className="field-label"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "12px",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--theme-elevation-800)",
|
||||
}}
|
||||
>
|
||||
Theme Color Selection
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(40px, 1fr))",
|
||||
gap: "12px",
|
||||
marginBottom: "16px",
|
||||
padding: "16px",
|
||||
background: "var(--theme-elevation-50)",
|
||||
border: "1px solid var(--theme-elevation-150)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
{PREDEFINED_COLORS.map((swatch) => {
|
||||
const isSelected = value === swatch.value;
|
||||
return (
|
||||
<button
|
||||
key={swatch.value}
|
||||
type="button"
|
||||
title={swatch.label}
|
||||
onClick={() => handleColorClick(swatch.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "1/1",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: swatch.color,
|
||||
border: isSelected
|
||||
? "3px solid var(--theme-text)"
|
||||
: "1px solid rgba(0,0,0,0.1)",
|
||||
boxShadow: isSelected
|
||||
? "0 0 0 2px var(--theme-elevation-150)"
|
||||
: "inset 0 2px 4px rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.1)",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
transform: isSelected ? "scale(1.05)" : "scale(1)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected)
|
||||
Object.assign(e.currentTarget.style, {
|
||||
transform: "scale(1.1)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
});
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected)
|
||||
Object.assign(e.currentTarget.style, {
|
||||
transform: "scale(1)",
|
||||
boxShadow:
|
||||
"inset 0 2px 4px rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.1)",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom Hex Fallback mapping */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
background: "var(--theme-elevation-50)",
|
||||
padding: "12px 16px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--theme-elevation-150)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--theme-elevation-600)",
|
||||
}}
|
||||
>
|
||||
Hex / String Value:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="e.g. #ff0000 or slate-500"
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
borderRadius: "6px",
|
||||
background: "#ffffff",
|
||||
color: "var(--theme-text)",
|
||||
fontSize: "0.875rem",
|
||||
width: "100%",
|
||||
maxWidth: "200px",
|
||||
boxShadow: "inset 0 1px 2px rgba(0,0,0,0.05)",
|
||||
outline: "none",
|
||||
}}
|
||||
onFocus={(e) => (e.target.style.borderColor = "var(--theme-primary)")}
|
||||
onBlur={(e) =>
|
||||
(e.target.style.borderColor = "var(--theme-elevation-200)")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useField, useDocumentInfo, useForm } from "@payloadcms/ui";
|
||||
import { generateSingleFieldAction } from "../../actions/generateField";
|
||||
|
||||
export function AiFieldButton({ path, field }: { path: string; field: any }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
// Payload hooks
|
||||
const { value, setValue } = useField<string>({ path });
|
||||
const { title } = useDocumentInfo();
|
||||
const { fields } = useForm();
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Field name is passed as a label usually, fallback to path
|
||||
const fieldName = typeof field?.label === "string" ? field.label : path;
|
||||
const fieldDescription =
|
||||
typeof field?.admin?.description === "string"
|
||||
? field.admin.description
|
||||
: "";
|
||||
|
||||
const res = await generateSingleFieldAction(
|
||||
(title as string) || "",
|
||||
draftContent,
|
||||
fieldName,
|
||||
fieldDescription,
|
||||
instructions,
|
||||
);
|
||||
if (res.success && res.text) {
|
||||
setValue(res.text);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Fehler bei der Generierung.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setShowInstructions(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
marginBottom: "8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
padding: "4px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isGenerating ? "✨ AI arbeitet..." : "✨ AI Ausfüllen"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowInstructions(!showInstructions);
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "var(--theme-elevation-500)",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
{showInstructions ? "Prompt verbergen" : "Mit Prompt..."}
|
||||
</button>
|
||||
</div>
|
||||
{showInstructions && (
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Eigene Anweisung an AI (z.B. 'als catchy slogan')"
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
}}
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
import { generateSlugAction } from "../../actions/generateField";
|
||||
import { Button } from "@payloadcms/ui";
|
||||
|
||||
export function GenerateSlugButton({ path }: { path: string }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGenerating) return;
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue =
|
||||
"Slug-Generierung läuft noch. Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isGenerating]);
|
||||
|
||||
const { fields, replaceState } = useForm();
|
||||
const { value, initialValue, setValue } = useField({ path });
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const title = (fields?.title?.value as string) || "";
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await generateSlugAction(
|
||||
title,
|
||||
draftContent,
|
||||
initialValue as string,
|
||||
instructions,
|
||||
);
|
||||
if (res.success && res.slug) {
|
||||
setValue(res.slug);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Unerwarteter Fehler.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center mb-4">
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Optionale AI Anweisung für den Slug..."
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "40px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="btn btn--icon-style-none btn--size-medium ml-auto"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||
transition: "all 0.2s ease",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="btn__content">
|
||||
{isGenerating ? "✨ Generiere (ca 10s)..." : "✨ AI Slug Generieren"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
import { generateThumbnailAction } from "../../actions/generateField";
|
||||
import { Button } from "@payloadcms/ui";
|
||||
|
||||
export function GenerateThumbnailButton({ path }: { path: string }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGenerating) return;
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue =
|
||||
"Bild-Generierung läuft noch (dies dauert bis zu 2 Minuten). Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isGenerating]);
|
||||
|
||||
const { fields } = useForm();
|
||||
const { value, setValue } = useField({ path });
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const title = (fields?.title?.value as string) || "";
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await generateThumbnailAction(
|
||||
draftContent,
|
||||
title,
|
||||
instructions,
|
||||
);
|
||||
if (res.success && res.mediaId) {
|
||||
setValue(res.mediaId);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Unerwarteter Fehler.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center mt-2 mb-4">
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Optionale Thumbnail-Detailanweisung (Farben, Stimmung, etc.)..."
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "40px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="btn btn--icon-style-none btn--size-medium"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||
transition: "all 0.2s ease",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="btn__content">
|
||||
{isGenerating
|
||||
? "✨ AI arbeitet (dauert ca. 1-2 Min)..."
|
||||
: "✨ AI Thumbnail Generieren"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
apps/web/src/payload/components/IconSelector/index.tsx
Normal file
191
apps/web/src/payload/components/IconSelector/index.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useField } from "@payloadcms/ui";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
|
||||
const COMMON_ICONS = [
|
||||
"Check",
|
||||
"X",
|
||||
"AlertTriangle",
|
||||
"Info",
|
||||
"ArrowRight",
|
||||
"ArrowUpRight",
|
||||
"ChevronRight",
|
||||
"Settings",
|
||||
"Tool",
|
||||
"Terminal",
|
||||
"Code",
|
||||
"Database",
|
||||
"Server",
|
||||
"Cpu",
|
||||
"Zap",
|
||||
"Shield",
|
||||
"Lock",
|
||||
"Key",
|
||||
"Eye",
|
||||
"Search",
|
||||
"Filter",
|
||||
"BarChart",
|
||||
"LineChart",
|
||||
"PieChart",
|
||||
"TrendingUp",
|
||||
"TrendingDown",
|
||||
"Users",
|
||||
"User",
|
||||
"Briefcase",
|
||||
"Building",
|
||||
"Globe",
|
||||
"Mail",
|
||||
"FileText",
|
||||
"File",
|
||||
"Folder",
|
||||
"Image",
|
||||
"Video",
|
||||
"MessageSquare",
|
||||
"Clock",
|
||||
"Calendar",
|
||||
"CheckCircle",
|
||||
"XCircle",
|
||||
"Play",
|
||||
"Pause",
|
||||
"Activity",
|
||||
"Box",
|
||||
"Layers",
|
||||
"Layout",
|
||||
"Monitor",
|
||||
"Smartphone",
|
||||
"Tablet",
|
||||
"Wifi",
|
||||
"Cloud",
|
||||
"Crosshair",
|
||||
"Target",
|
||||
"Trophy",
|
||||
"Star",
|
||||
"Heart",
|
||||
];
|
||||
|
||||
export default function IconSelectorField({ path }: { path: string }) {
|
||||
const { value, setValue } = useField<string>({ path });
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredIcons = COMMON_ICONS.filter((name) =>
|
||||
name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleIconClick = (iconName: string) => {
|
||||
// Toggle off if clicking the current value
|
||||
if (value === iconName) {
|
||||
setValue(null);
|
||||
} else {
|
||||
setValue(iconName);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="field-type" style={{ marginBottom: "1.5rem" }}>
|
||||
<label
|
||||
className="field-label"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Icon Selection (Lucide)
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search icons..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
borderRadius: "4px",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
fontSize: "0.875rem",
|
||||
width: "100%",
|
||||
marginBottom: "12px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(40px, 1fr))",
|
||||
gap: "8px",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
padding: "4px",
|
||||
}}
|
||||
>
|
||||
{filteredIcons.map((iconName) => {
|
||||
const Icon = (LucideIcons as any)[iconName];
|
||||
if (!Icon) return null;
|
||||
const isSelected = value === iconName;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
title={iconName}
|
||||
onClick={() => handleIconClick(iconName)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "6px",
|
||||
background: isSelected
|
||||
? "var(--theme-elevation-200)"
|
||||
: "transparent",
|
||||
border: isSelected
|
||||
? "1px solid var(--theme-elevation-400)"
|
||||
: "1px solid var(--theme-elevation-150)",
|
||||
color: isSelected
|
||||
? "var(--theme-text)"
|
||||
: "var(--theme-elevation-500)",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.1s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isSelected
|
||||
? "var(--theme-elevation-200)"
|
||||
: "var(--theme-elevation-100)";
|
||||
e.currentTarget.style.color = "var(--theme-text)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = isSelected
|
||||
? "var(--theme-elevation-200)"
|
||||
: "transparent";
|
||||
e.currentTarget.style.color = isSelected
|
||||
? "var(--theme-text)"
|
||||
: "var(--theme-elevation-500)";
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredIcons.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--theme-elevation-500)",
|
||||
marginTop: "8px",
|
||||
}}
|
||||
>
|
||||
No matching icons found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
apps/web/src/payload/components/OptimizeButton.tsx
Normal file
136
apps/web/src/payload/components/OptimizeButton.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useDocumentInfo } from "@payloadcms/ui";
|
||||
import { optimizePostText } from "../actions/optimizePost";
|
||||
import { Button } from "@payloadcms/ui";
|
||||
|
||||
export function OptimizeButton() {
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOptimizing) return;
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue =
|
||||
"Lexical Block-Optimierung läuft noch (dies dauert bis zu 45 Sekunden). Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isOptimizing]);
|
||||
|
||||
const { fields, setModified, replaceState } = useForm();
|
||||
const { title } = useDocumentInfo();
|
||||
|
||||
const handleOptimize = async () => {
|
||||
// ... gathering draftContent logic
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
|
||||
let draftContent = legacyValue || "";
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
if (!draftContent || draftContent.trim().length < 50) {
|
||||
alert(
|
||||
"Der Entwurf ist zu kurz. Bitte tippe zuerst ein paar Stichpunkte oder einen Rohling ein.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOptimizing(true);
|
||||
try {
|
||||
// 2. We inject the title so the AI knows what it's writing about
|
||||
const payloadText = `---\ntitle: "${title}"\n---\n\n${draftContent}`;
|
||||
|
||||
const response = await optimizePostText(payloadText, instructions);
|
||||
|
||||
if (response.success && response.lexicalAST) {
|
||||
// 3. Inject the new Lexical AST directly into the field form state
|
||||
// We use Payload's useForm hook replacing the value of the 'content' field.
|
||||
|
||||
replaceState({
|
||||
...fields,
|
||||
content: {
|
||||
...fields.content,
|
||||
value: response.lexicalAST,
|
||||
initialValue: response.lexicalAST,
|
||||
},
|
||||
});
|
||||
|
||||
setModified(true);
|
||||
alert(
|
||||
"🎉 Artikel wurde erfolgreich von der AI optimiert und mit Lexical Components angereichert.",
|
||||
);
|
||||
} else {
|
||||
alert("❌ Fehler: " + response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Optimization failed:", error);
|
||||
alert("Ein unerwarteter Fehler ist aufgetreten.");
|
||||
} finally {
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 p-4 bg-slate-50 border border-slate-200 rounded-md">
|
||||
<h3 className="text-sm font-semibold mb-2">AI Post Optimizer</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Lass Mintel AI deinen Text-Rohentwurf analysieren und automatisch in
|
||||
einen voll formatierten Lexical Artikel mit passenden B2B Komponenten
|
||||
(MemeCards, Mermaids) umwandeln.
|
||||
</p>
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Optionale Anweisungen an die AI (z.B. 'schreibe etwas lockerer' oder 'fokussiere dich auf SEO')..."
|
||||
disabled={isOptimizing}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "60px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOptimize}
|
||||
disabled={isOptimizing}
|
||||
className="btn btn--icon-style-none btn--size-medium mt-4"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||
transition: "all 0.2s ease",
|
||||
opacity: isOptimizing ? 0.7 : 1,
|
||||
cursor: isOptimizing ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="btn__content" style={{ fontWeight: 600 }}>
|
||||
{isOptimizing ? "✨ AI arbeitet (ca 30s)..." : "✨ Jetzt optimieren"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
apps/web/src/payload/components/TagSelector/index.tsx
Normal file
131
apps/web/src/payload/components/TagSelector/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useField } from "@payloadcms/ui";
|
||||
|
||||
export default function TagSelectorField({ path }: { path: string }) {
|
||||
const { value, setValue } = useField<string>({ path });
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/posts?depth=0&limit=1000");
|
||||
const data = await res.json();
|
||||
if (data && data.docs) {
|
||||
const allTags = new Set<string>();
|
||||
data.docs.forEach((post: any) => {
|
||||
if (post.tags && Array.isArray(post.tags)) {
|
||||
post.tags.forEach((t: any) => {
|
||||
if (t.tag) allTags.add(t.tag);
|
||||
});
|
||||
}
|
||||
});
|
||||
setSuggestions(Array.from(allTags).sort());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load tags for suggestions", err);
|
||||
}
|
||||
};
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: "1rem", position: "relative" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "4px",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--theme-elevation-500)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
Tag Name
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setTimeout(() => setIsFocused(false), 200)}
|
||||
placeholder="e.g. Next.js"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
borderRadius: "4px",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
fontSize: "0.875rem",
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
outline: "none",
|
||||
transition: "border-color 0.2s",
|
||||
}}
|
||||
/>
|
||||
|
||||
{isFocused && suggestions.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
borderRadius: "4px",
|
||||
marginTop: "2px",
|
||||
maxHeight: "120px",
|
||||
overflowY: "auto",
|
||||
boxShadow: "0 10px 15px -3px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
{suggestions.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
setValue(tag);
|
||||
setIsFocused(false);
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--theme-text)",
|
||||
borderBottom: "1px solid var(--theme-elevation-50)",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
"var(--theme-elevation-100)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = "transparent")
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!value && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
color: "var(--theme-elevation-400)",
|
||||
marginTop: "2px",
|
||||
}}
|
||||
>
|
||||
Select from list or type a new tag.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user