feat: integrate feedback module

This commit is contained in:
2026-02-08 21:48:55 +01:00
parent b60ba17770
commit 4ca4744a8c
48 changed files with 4413 additions and 1168 deletions

12
.env
View File

@@ -2,15 +2,9 @@
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
# WooCommerce & WordPress
WOOCOMMERCE_URL=https://klz-cables.com
WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
WORDPRESS_APP_PASSWORD="DlJH 49dp fC3a Itc3 Sl7Z Wz0k"
NEXT_PUBLIC_FEEDBACK_ENABLED=true
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
@@ -26,11 +20,15 @@ DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
# Local Development
PROJECT_NAME=klz-cables
GATEKEEPER_BYPASS_ENABLED=true
TRAEFIK_HOST=klz.localhost
DIRECTUS_HOST=cms.klz.localhost
GATEKEEPER_PASSWORD=klz2026
COOKIE_DOMAIN=localhost
INFRA_DIRECTUS_URL=http://localhost:8059
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e

View File

@@ -10,11 +10,11 @@
# ────────────────────────────────────────────────────────────────────────────
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000
DIRECTUS_PORT=8055
# TARGET is used to differentiate between environments (testing, staging, production)
# NEXT_PUBLIC_TARGET makes this information available to the frontend
NEXT_PUBLIC_TARGET=development
# TARGET is used server-side
TARGET=development
NEXT_PUBLIC_FEEDBACK_ENABLED=false
# ────────────────────────────────────────────────────────────────────────────
# Analytics (Umami)

View File

@@ -327,7 +327,9 @@ jobs:
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
set -e
mkdir -p /home/deploy/sites/klz-cables.com/varnish
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads /home/deploy/sites/klz-cables.com/directus/extensions
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads \
/home/deploy/sites/klz-cables.com/directus/extensions \
/home/deploy/sites/klz-cables.com/directus/schema
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
@@ -338,6 +340,7 @@ jobs:
# 2. Transfer files
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
scp -r -o StrictHostKeyChecking=accept-new directus/schema root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/directus/
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
@@ -361,6 +364,14 @@ jobs:
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
exit 1
fi
echo "→ Applying Directus Schema Snapshot..."
# Note: We check if snapshot exists first to avoid failing if no snapshot is committed yet
if docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes
else
echo " No snapshot.yaml found, skipping schema apply."
fi
echo "→ Verifying Varnish Backend Health..."
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list

4
.gitignore vendored
View File

@@ -4,4 +4,6 @@ node_modules
# Directus
directus/uploads
!directus/extensions/
!directus/extensions/
!directus/schema/
!directus/migrations/

View File

@@ -9,10 +9,10 @@ import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
interface PageProps {
params: {
params: Promise<{
locale: string;
slug: string;
};
}>;
}
export async function generateStaticParams() {
@@ -29,7 +29,8 @@ export async function generateStaticParams() {
return params;
}
export async function generateMetadata({ params: { locale, slug } }: PageProps): Promise<Metadata> {
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale);
if (!pageData) return {};
@@ -59,7 +60,8 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
};
}
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
export default async function StandardPage({ params }: PageProps) {
const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale);
const t = await getTranslations('StandardPage');

View File

@@ -14,15 +14,14 @@ import { Heading } from '@/components/ui';
import { getOGImageMetadata } from '@/lib/metadata';
interface BlogPostProps {
params: {
params: Promise<{
locale: string;
slug: string;
};
}>;
}
export async function generateMetadata({
params: { locale, slug },
}: BlogPostProps): Promise<Metadata> {
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
const { locale, slug } = await params;
const post = await getPostBySlug(slug, locale);
if (!post) return {};
@@ -56,7 +55,8 @@ export async function generateMetadata({
};
}
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
export default async function BlogPost({ params }: BlogPostProps) {
const { locale, slug } = await params;
const post = await getPostBySlug(slug, locale);
const { prev, next } = await getAdjacentPosts(slug, locale);

View File

@@ -7,12 +7,13 @@ import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
interface BlogIndexProps {
params: {
params: Promise<{
locale: string;
};
}>;
}
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
export async function generateMetadata({ params }: BlogIndexProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
return {
title: t('title'),
@@ -39,7 +40,8 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
};
}
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
export default async function BlogIndex({ params }: BlogIndexProps) {
const { locale } = await params;
const t = await getTranslations('Blog');
const posts = await getAllPosts(locale);

View File

@@ -3,6 +3,7 @@ import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { FeedbackOverlay } from '@/components/feedback/FeedbackOverlay';
import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
@@ -32,27 +33,38 @@ export const viewport: Viewport = {
export default async function LocaleLayout({
children,
params: { locale },
params,
}: {
children: React.ReactNode;
params: { locale: string };
params: Promise<{ locale: string }>;
}) {
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
const { locale } = await params;
// Ensure locale is a valid string, fallback to 'en'
const supportedLocales = ['en', 'de'];
const localeStr = (typeof locale === 'string' ? locale : '').trim();
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
let messages = {};
try {
messages = await getMessages();
} catch (error) {
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
messages = {};
}
return (
<html lang={locale} className="scroll-smooth overflow-x-hidden">
<html lang={safeLocale} className="scroll-smooth overflow-x-hidden">
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
<NextIntlClientProvider messages={messages} locale={locale}>
<NextIntlClientProvider messages={messages} locale={safeLocale}>
<JsonLd />
<Header />
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
<Footer />
<CMSConnectivityNotice />
{/* Sends pageviews for client-side navigations */}
<AnalyticsProvider />
{config.feedbackEnabled && <FeedbackOverlay />}
</NextIntlClientProvider>
</body>
</html>

View File

@@ -15,7 +15,12 @@ import { getTranslations } from 'next-intl/server';
import { Metadata } from 'next';
import { getOGImageMetadata } from '@/lib/metadata';
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<div className="flex flex-col min-h-screen">
<JsonLd
@@ -55,10 +60,11 @@ export default function HomePage({ params: { locale } }: { params: { locale: str
}
export async function generateMetadata({
params: { locale },
params,
}: {
params: { locale: string };
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
// Use translations for meta where available (namespace: Index.meta)
// Fallback to a sensible default if translation keys are missing.
let t;

View File

@@ -9,12 +9,13 @@ import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';
interface TeamPageProps {
params: {
params: Promise<{
locale: string;
};
}>;
}
export async function generateMetadata({ params: { locale } }: TeamPageProps): Promise<Metadata> {
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
@@ -43,7 +44,8 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
};
}
export default async function TeamPage({ params: { locale } }: TeamPageProps) {
export default async function TeamPage({ params }: TeamPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
return (

79
app/api/feedback/route.ts Normal file
View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { createDirectus, rest, authentication, staticToken, createItem, readItems } from '@directus/sdk';
import { config } from '@/lib/config';
async function getAuthenticatedClient() {
const { url, token: rawToken } = config.infraCMS;
const effectiveUrl = url;
const token = rawToken?.trim();
if (!token) {
throw new Error('INFRA_DIRECTUS_TOKEN is not configured');
}
const client = createDirectus(effectiveUrl)
.with(staticToken(token))
.with(rest());
return client;
}
export async function GET() {
try {
const client = await getAuthenticatedClient();
const items = await client.request(readItems('visual_feedback', {
fields: ['*'],
sort: ['-date_created'],
}));
return NextResponse.json(items);
} catch (error: any) {
const errMsg = error.errors?.[0]?.message || error.message || 'Unknown Directus Error';
console.error('Error fetching feedback:', {
msg: errMsg,
url: config.infraCMS.url,
status: error.response?.status,
errors: error.errors
});
return NextResponse.json({ error: errMsg }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const client = await getAuthenticatedClient();
const body = await req.json();
const { action, ...data } = body;
if (action === 'reply') {
const reply = await client.request(createItem('visual_feedback_comments', {
feedback_id: data.feedbackId,
user_name: data.userName,
text: data.text,
}));
return NextResponse.json(reply);
}
const feedback = await client.request(createItem('visual_feedback', {
project: 'klz-cables',
url: data.url,
selector: data.selector,
x: data.x,
y: data.y,
type: data.type,
text: data.text,
user_name: data.userName,
user_identity: data.userIdentity,
}));
return NextResponse.json(feedback);
} catch (error: any) {
const errMsg = error.errors?.[0]?.message || error.message || 'Unknown Directus Error';
console.error('Error saving feedback:', {
msg: errMsg,
url: config.infraCMS.url,
status: error.response?.status,
errors: error.errors
});
return NextResponse.json({ error: errMsg }, { status: 500 });
}
}

43
app/api/whoami/route.ts Normal file
View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { envSchema, getRawEnv } from '@/lib/env';
export async function GET(req: NextRequest) {
const env = envSchema.parse(getRawEnv());
const gatekeeperUrl = env.GATEKEEPER_URL;
const host = req.headers.get('host') || '';
const { searchParams } = new URL(req.url);
const hasBypassParam = searchParams.get('gatekeeper_bypass') === 'true';
const isLocal = host.includes('localhost') || host.includes('127.0.0.1') || host.includes('klz.localhost');
const isBypassEnabled = hasBypassParam || env.GATEKEEPER_BYPASS_ENABLED || (env.NODE_ENV === 'development' && isLocal);
// If bypass is enabled or we are in local development, use "Dev-Admin" identity.
if (isBypassEnabled) {
return NextResponse.json({
authenticated: true,
identity: 'Dev-Admin',
isDevFallback: true
});
}
try {
// We forward the cookie header to gatekeeper so it can identify the session
const response = await fetch(`${gatekeeperUrl}/api/whoami`, {
headers: {
cookie: req.headers.get('cookie') || '',
},
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json({ authenticated: false, identity: 'Guest' });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error: any) {
console.error('Error proxying to gatekeeper:', error);
return NextResponse.json({ authenticated: false, identity: 'Guest (Auth Error)' });
}
}

View File

@@ -0,0 +1,539 @@
'use client';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { MessageSquare, X, Check, MousePointer2, Plus, List, Send, User } from 'lucide-react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
function cn(...inputs: any[]) {
return twMerge(clsx(inputs));
}
interface FeedbackComment {
id: string;
userName: string;
text: string;
createdAt: string;
}
interface Feedback {
id: string;
x: number;
y: number;
selector: string;
text: string;
type: 'design' | 'content';
elementRect: DOMRect | null;
userName: string;
comments: FeedbackComment[];
}
export function FeedbackOverlay() {
const [isActive, setIsActive] = useState(false);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
const [currentComment, setCurrentComment] = useState('');
const [currentType, setCurrentType] = useState<'design' | 'content'>('design');
const [showList, setShowList] = useState(false);
const [currentUser, setCurrentUser] = useState<{ identity: string, isDevFallback?: boolean } | null>(null);
const [newCommentTexts, setNewCommentTexts] = useState<{ [feedbackId: string]: string }>({});
// 1. Fetch Identity and Existing Feedback
useEffect(() => {
const checkAuth = async () => {
try {
// Determine if we have a bypass parameter in the URL
const urlParams = new URLSearchParams(window.location.search);
const bypass = urlParams.get('gatekeeper_bypass');
const apiUrl = bypass ? `/api/whoami?gatekeeper_bypass=${bypass}` : '/api/whoami';
const res = await fetch(apiUrl);
if (res.ok) {
const data = await res.json();
setCurrentUser(data);
} else {
setCurrentUser({ identity: "Guest" });
}
} catch (e) {
setCurrentUser({ identity: "Guest" });
}
};
const fetchFeedback = async () => {
try {
const res = await fetch('/api/feedback');
if (res.ok) {
const data = await res.json();
// Map Directus fields back to our interface if necessary
const mapped = data.map((fb: any) => ({
id: fb.id,
x: fb.x,
y: fb.y,
selector: fb.selector,
text: fb.text,
type: fb.type,
userName: fb.user_name,
comments: (fb.comments || []).map((c: any) => ({
id: c.id,
userName: c.user_name,
text: c.text,
createdAt: c.date_created
}))
}));
setFeedbacks(mapped);
}
} catch (e) {
console.error("Failed to fetch feedbacks", e);
}
};
checkAuth();
fetchFeedback();
}, []);
// Helper to get unique selector
const getSelector = (el: HTMLElement): string => {
if (el.id) return `#${el.id}`;
let path = [];
while (el.parentElement) {
let index = Array.from(el.parentElement.children).indexOf(el) + 1;
path.unshift(`${el.tagName.toLowerCase()}:nth-child(${index})`);
el = el.parentElement;
}
return path.join(' > ');
};
useEffect(() => {
if (!isActive) {
setHoveredElement(null);
return;
}
const handleMouseMove = (e: MouseEvent) => {
if (selectedElement) return;
const target = e.target as HTMLElement;
if (target.closest('.feedback-ui-ignore')) {
setHoveredElement(null);
return;
}
setHoveredElement(target);
};
const handleClick = (e: MouseEvent) => {
if (selectedElement) return;
const target = e.target as HTMLElement;
if (target.closest('.feedback-ui-ignore')) return;
e.preventDefault();
e.stopPropagation();
setSelectedElement(target);
setHoveredElement(null);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('click', handleClick, true);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('click', handleClick, true);
};
}, [isActive, selectedElement]);
const saveFeedback = async () => {
if (!selectedElement || !currentComment) return;
const rect = selectedElement.getBoundingClientRect();
const feedbackData = {
url: window.location.href,
x: rect.left + rect.width / 2 + window.scrollX,
y: rect.top + rect.height / 2 + window.scrollY,
selector: getSelector(selectedElement),
text: currentComment,
type: currentType,
userName: currentUser?.identity || "Unknown",
userIdentity: currentUser?.identity === 'Admin' ? 'admin' : 'user'
};
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feedbackData)
});
if (res.ok) {
const savedFb = await res.json();
const newFeedback: Feedback = {
id: savedFb.id,
x: savedFb.x,
y: savedFb.y,
selector: savedFb.selector,
text: savedFb.text,
type: savedFb.type,
elementRect: rect,
userName: savedFb.user_name,
comments: [],
};
setFeedbacks([...feedbacks, newFeedback]);
setSelectedElement(null);
setCurrentComment('');
}
} catch (e) {
console.error("Failed to save feedback", e);
}
};
const addReply = async (feedbackId: string) => {
const text = newCommentTexts[feedbackId];
if (!text) return;
if (!currentUser?.identity || currentUser.identity === 'Guest') {
alert("Nur angemeldete Benutzer können antworten.");
return;
}
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'reply',
feedbackId,
userName: currentUser?.identity || "Unknown",
text
})
});
if (res.ok) {
const savedReply = await res.json();
setFeedbacks(feedbacks.map(f => {
if (f.id === feedbackId) {
return {
...f,
comments: [...f.comments, {
id: savedReply.id,
userName: savedReply.user_name,
text: savedReply.text,
createdAt: savedReply.date_created
}]
};
}
return f;
}));
setNewCommentTexts({ ...newCommentTexts, [feedbackId]: '' });
}
} catch (e) {
console.error("Failed to save reply", e);
}
};
const hoveredRect = useMemo(() => hoveredElement?.getBoundingClientRect(), [hoveredElement]);
const selectedRect = useMemo(() => selectedElement?.getBoundingClientRect(), [selectedElement]);
return (
<div className="feedback-ui-ignore">
{/* 1. Global Toolbar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999]">
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-2">
<div className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl transition-all",
currentUser?.isDevFallback ? "bg-orange-500/20 text-orange-400" : "bg-white/5 text-white/40"
)}>
<User size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">
{currentUser?.identity || "Loading..."}
{currentUser?.isDevFallback && " (Local Dev Bypass)"}
</span>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => {
if (!currentUser?.identity || currentUser.identity === 'Guest') {
// Maybe show a toast or just stay disabled
alert("Bitte logge dich ein, um Feedback zu geben.");
return;
}
setIsActive(!isActive);
}}
disabled={!currentUser?.identity || currentUser.identity === 'Guest'}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium disabled:opacity-30 disabled:cursor-not-allowed",
isActive
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
: "text-white/70 hover:text-white hover:bg-white/10"
)}
>
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
{isActive ? "Modus beenden" : "Feedback geben"}
</button>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => setShowList(!showList)}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
>
<List size={20} />
{feedbacks.length > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
{feedbacks.length}
</span>
)}
</button>
</div>
</div>
{/* 2. Feedback Markers & Highlights */}
<AnimatePresence>
{isActive && (
<>
{/* Fixed Overlay for real-time highlights */}
<div className="fixed inset-0 pointer-events-none z-[9998]">
{hoveredRect && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
style={{
top: hoveredRect.top,
left: hoveredRect.left,
width: hoveredRect.width,
height: hoveredRect.height,
}}
/>
)}
{selectedRect && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
style={{
top: selectedRect.top,
left: selectedRect.left,
width: selectedRect.width,
height: selectedRect.height,
}}
/>
)}
</div>
{/* Absolute Overlay for persistent pins */}
<div className="absolute inset-0 pointer-events-none z-[9997]">
{feedbacks.map((fb) => (
<div
key={fb.id}
className="absolute"
style={{ top: fb.y, left: fb.x }}
>
<button
onClick={() => {
setShowList(true);
// TODO: Scroll to feedback in list
}}
className={cn(
"w-6 h-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white cursor-pointer pointer-events-auto transition-transform hover:scale-110",
fb.type === 'design' ? 'bg-purple-500' : 'bg-orange-500'
)}
>
<Plus size={14} className="rotate-45" />
</button>
</div>
))}
</div>
</>
)}
</AnimatePresence>
{/* 3. Feedback Modal */}
<AnimatePresence>
{selectedElement && (
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-6 w-[400px] shadow-2xl"
>
<div className="flex items-center justify-between mb-6">
<h3 className="text-white font-bold text-lg">Feedback geben</h3>
<button
onClick={() => setSelectedElement(null)}
className="text-white/40 hover:text-white"
>
<X size={20} />
</button>
</div>
<div className="flex gap-2 mb-6">
{(['design', 'content'] as const).map((type) => (
<button
key={type}
onClick={() => setCurrentType(type)}
className={cn(
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all capitalize",
currentType === type
? "bg-white text-black shadow-lg"
: "bg-white/5 text-white/40 hover:bg-white/10"
)}
>
{type === 'design' ? '🎨 Design' : '✍️ Content'}
</button>
))}
</div>
<textarea
autoFocus
value={currentComment}
onChange={(e) => setCurrentComment(e.target.value)}
placeholder="Was möchtest du anmerken?"
className="w-full h-32 bg-white/5 border border-white/5 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none mb-6"
/>
<button
disabled={!currentComment}
onClick={saveFeedback}
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20"
>
<Check size={20} />
Feedback speichern
</button>
</motion.div>
</div>
)}
</AnimatePresence>
{/* 4. Feedback List Sidebar */}
<AnimatePresence>
{showList && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowList(false)}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 h-full w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
>
<div className="p-8 border-b border-white/10 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white mb-1">Feedback</h2>
<p className="text-white/40 text-sm">{feedbacks.length} Anmerkungen live</p>
</div>
<button
onClick={() => setShowList(false)}
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{feedbacks.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
<MessageSquare size={48} className="mb-4" />
<p>Noch kein Feedback vorhanden. Aktiviere den Modus um Stellen auf der Seite zu markieren.</p>
</div>
) : (
feedbacks.map((fb) => (
<div
key={fb.id}
className="bg-white/5 border border-white/5 rounded-3xl overflow-hidden hover:border-white/20 transition-all flex flex-col"
>
{/* Header */}
<div className="p-5 border-b border-white/5 bg-white/[0.02]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center text-blue-400">
<User size={14} />
</div>
<div>
<p className="text-white text-[11px] font-bold uppercase tracking-wider">{fb.userName}</p>
<p className="text-white/20 text-[9px] uppercase tracking-widest">Original Poster</p>
</div>
</div>
<span className={cn(
"px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-wider",
fb.type === 'design' ? 'bg-purple-500/20 text-purple-400' : 'bg-orange-500/20 text-orange-400'
)}>
{fb.type}
</span>
</div>
<p className="text-white/80 whitespace-pre-wrap text-sm leading-relaxed">{fb.text}</p>
<div className="mt-3 flex items-center gap-2">
<div className="w-1 h-1 bg-white/10 rounded-full" />
<span className="text-white/20 text-[9px] truncate tracking-wider italic">
{fb.selector}
</span>
</div>
</div>
{/* Comments List */}
{fb.comments.length > 0 && (
<div className="bg-black/20 p-5 space-y-4">
{fb.comments.map(comment => (
<div key={comment.id} className="flex gap-3">
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center text-white/40 shrink-0">
<User size={10} />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-[10px] font-bold text-white/60 uppercase">{comment.userName}</p>
<p className="text-[10px] text-white/20">
{new Date(comment.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<p className="text-white/80 text-xs leading-snug">{comment.text}</p>
</div>
</div>
))}
</div>
)}
{/* Reply Input */}
<div className="p-4 bg-white/[0.01] mt-auto border-t border-white/5">
<div className="relative">
<input
type="text"
value={newCommentTexts[fb.id] || ''}
onChange={(e) => setNewCommentTexts({ ...newCommentTexts, [fb.id]: e.target.value })}
placeholder="Antworten..."
className="w-full bg-black/40 border border-white/5 rounded-2xl py-3 pl-4 pr-12 text-xs text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter') addReply(fb.id);
}}
/>
<button
onClick={() => addReply(fb.id)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-blue-500 hover:text-blue-400 transition-colors disabled:opacity-30"
disabled={!newCommentTexts[fb.id]}
>
<Send size={14} />
</button>
</div>
</div>
</div>
))
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -72,7 +72,7 @@ export default function Hero() {
>
<HeroIllustration />
</motion.div>
<motion.div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
initial={{ opacity: 0, y: 16 }}

View File

View File

@@ -0,0 +1,590 @@
version: 1
directus: 11.14.1
vendor: postgres
collections:
- collection: contact_submissions
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: contact_submissions
color: '#002b49'
display_template: '{{first_name}} {{last_name}} | {{subject}}'
group: null
hidden: false
icon: contact_mail
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: contact_submissions
- collection: product_requests
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: product_requests
color: '#002b49'
display_template: null
group: null
hidden: false
icon: inventory
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: product_requests
- collection: visual_feedback
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: visual_feedback
color: '#002b49'
display_template: '{{user_name}} | {{type}}: {{text}}'
group: null
hidden: false
icon: feedback
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: visual_feedback
- collection: visual_feedback_comments
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: visual_feedback_comments
color: '#002b49'
display_template: '{{user_name}}: {{text}}'
group: null
hidden: false
icon: comment
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: visual_feedback_comments
fields:
- collection: visual_feedback
field: id
type: uuid
meta:
collection: visual_feedback
conditions: null
display: null
display_options: null
field: id
group: null
hidden: true
interface: null
note: null
options: null
readonly: false
required: false
searchable: true
sort: 1
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: id
table: visual_feedback
data_type: uuid
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: true
is_indexed: false
is_primary_key: true
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: status
type: string
meta:
collection: visual_feedback
conditions: null
display: labels
display_options:
choices:
- background: '#e1f5fe'
color: '#01579b'
text: Open
value: open
- background: '#e8f5e9'
color: '#1b5e20'
text: Resolved
value: resolved
- background: '#fafafa'
color: '#212121'
text: Closed
value: closed
show_as_dot: true
field: status
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Open
value: open
- text: Resolved
value: resolved
- text: Closed
value: closed
readonly: false
required: false
searchable: true
sort: 2
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: status
table: visual_feedback
data_type: character varying
default_value: open
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: type
type: string
meta:
collection: visual_feedback
conditions: null
display: labels
display_options:
choices:
- background: '#fff9c4'
color: '#fbc02d'
text: Design
value: design
- background: '#f3e5f5'
color: '#7b1fa2'
text: Content
value: content
show_as_dot: true
field: type
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Design
value: design
- text: Content
value: content
readonly: false
required: false
searchable: true
sort: 3
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: type
table: visual_feedback
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: text
type: text
meta:
collection: visual_feedback
conditions: null
display: formatted-text
display_options:
soft_limit: 100
field: text
group: null
hidden: false
interface: input-multiline
note: null
options: null
readonly: false
required: false
searchable: true
sort: 4
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: text
table: visual_feedback
data_type: text
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: url
type: string
meta:
collection: visual_feedback
conditions: null
display: link
display_options:
url: '{{url}}'
target: _blank
icon: open_in_new
field: url
group: null
hidden: false
interface: input
note: null
options: null
readonly: true
required: false
searchable: true
sort: 5
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: url
table: visual_feedback
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: user_info_group
type: alias
meta:
collection: visual_feedback
field: user_info_group
group: null
hidden: false
interface: group-detail
options:
header_icon: person
header_text: User Information
sort: 6
special:
- alias
- no-data
- group
width: full
- collection: visual_feedback
field: user_name
type: string
meta:
collection: visual_feedback
field: user_name
group: user_info_group
hidden: false
interface: input
sort: 1
width: half
schema:
name: user_name
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: user_identity
type: string
meta:
collection: visual_feedback
field: user_identity
group: user_info_group
hidden: false
interface: input
readonly: true
sort: 2
width: half
schema:
name: user_identity
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: technical_details_group
type: alias
meta:
collection: visual_feedback
field: technical_details_group
group: null
hidden: false
interface: group-detail
options:
header_icon: psychology
header_text: Technical Context
sort: 7
special:
- alias
- no-data
- group
width: full
- collection: visual_feedback
field: selector
type: string
meta:
collection: visual_feedback
field: selector
group: technical_details_group
hidden: false
interface: input
readonly: true
sort: 1
width: full
schema:
name: selector
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: x
type: float
meta:
collection: visual_feedback
field: x
group: technical_details_group
hidden: false
interface: input
sort: 2
width: half
schema:
name: x
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: 'y'
type: float
meta:
collection: visual_feedback
field: 'y'
group: technical_details_group
hidden: false
interface: input
sort: 3
width: half
schema:
name: 'y'
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: date_created
type: timestamp
meta:
collection: visual_feedback
conditions: null
display: datetime
display_options:
relative: true
field: date_created
group: null
hidden: false
interface: datetime
note: null
options: null
readonly: true
required: false
searchable: true
sort: 8
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: date_created
table: visual_feedback
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
is_nullable: true
- collection: visual_feedback_comments
field: id
type: uuid
meta:
collection: visual_feedback_comments
field: id
hidden: true
schema:
name: id
table: visual_feedback_comments
data_type: uuid
is_primary_key: true
- collection: visual_feedback_comments
field: feedback_id
type: uuid
meta:
collection: visual_feedback_comments
display: null
field: feedback_id
interface: select-relational
sort: 2
width: full
schema:
name: feedback_id
table: visual_feedback_comments
data_type: uuid
- collection: visual_feedback_comments
field: user_name
type: string
meta:
collection: visual_feedback_comments
field: user_name
interface: input
sort: 3
width: half
schema:
name: user_name
table: visual_feedback_comments
data_type: character varying
- collection: visual_feedback_comments
field: text
type: text
meta:
collection: visual_feedback_comments
field: text
interface: input-multiline
sort: 4
width: full
schema:
name: text
table: visual_feedback_comments
data_type: text
- collection: visual_feedback_comments
field: date_created
type: timestamp
meta:
collection: visual_feedback_comments
display: datetime
display_options:
relative: true
field: date_created
interface: datetime
readonly: true
sort: 5
width: full
schema:
name: date_created
table: visual_feedback_comments
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
systemFields:
- collection: directus_activity
field: timestamp
schema:
is_indexed: true
- collection: directus_revisions
field: activity
schema:
is_indexed: true
- collection: directus_revisions
field: parent
schema:
is_indexed: true
relations:
- collection: visual_feedback_comments
field: feedback_id
related_collection: visual_feedback
schema:
column: feedback_id
foreign_key_column: id
foreign_key_table: visual_feedback
table: visual_feedback_comments
meta:
junction_field: null
many_collection: visual_feedback_comments
many_field: feedback_id
one_allowed_m2m: false
one_collection: visual_feedback
one_deselect_action: nullify
one_field: null
sort_field: null

View File

@@ -0,0 +1,590 @@
version: 1
directus: 11.14.1
vendor: postgres
collections:
- collection: contact_submissions
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: contact_submissions
color: '#002b49'
display_template: '{{first_name}} {{last_name}} | {{subject}}'
group: null
hidden: false
icon: contact_mail
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: contact_submissions
- collection: product_requests
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: product_requests
color: '#002b49'
display_template: null
group: null
hidden: false
icon: inventory
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: product_requests
- collection: visual_feedback
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: visual_feedback
color: '#002b49'
display_template: '{{user_name}} | {{type}}: {{text}}'
group: null
hidden: false
icon: feedback
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: visual_feedback
- collection: visual_feedback_comments
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: visual_feedback_comments
color: '#002b49'
display_template: '{{user_name}}: {{text}}'
group: null
hidden: false
icon: comment
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: visual_feedback_comments
fields:
- collection: visual_feedback
field: id
type: uuid
meta:
collection: visual_feedback
conditions: null
display: null
display_options: null
field: id
group: null
hidden: true
interface: null
note: null
options: null
readonly: false
required: false
searchable: true
sort: 1
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: id
table: visual_feedback
data_type: uuid
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: true
is_indexed: false
is_primary_key: true
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: status
type: string
meta:
collection: visual_feedback
conditions: null
display: labels
display_options:
choices:
- background: '#e1f5fe'
color: '#01579b'
text: Open
value: open
- background: '#e8f5e9'
color: '#1b5e20'
text: Resolved
value: resolved
- background: '#fafafa'
color: '#212121'
text: Closed
value: closed
show_as_dot: true
field: status
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Open
value: open
- text: Resolved
value: resolved
- text: Closed
value: closed
readonly: false
required: false
searchable: true
sort: 2
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: status
table: visual_feedback
data_type: character varying
default_value: open
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: type
type: string
meta:
collection: visual_feedback
conditions: null
display: labels
display_options:
choices:
- background: '#fff9c4'
color: '#fbc02d'
text: Design
value: design
- background: '#f3e5f5'
color: '#7b1fa2'
text: Content
value: content
show_as_dot: true
field: type
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Design
value: design
- text: Content
value: content
readonly: false
required: false
searchable: true
sort: 3
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: type
table: visual_feedback
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: text
type: text
meta:
collection: visual_feedback
conditions: null
display: formatted-text
display_options:
soft_limit: 100
field: text
group: null
hidden: false
interface: input-multiline
note: null
options: null
readonly: false
required: false
searchable: true
sort: 4
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: text
table: visual_feedback
data_type: text
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: url
type: string
meta:
collection: visual_feedback
conditions: null
display: link
display_options:
url: '{{url}}'
target: _blank
icon: open_in_new
field: url
group: null
hidden: false
interface: input
note: null
options: null
readonly: true
required: false
searchable: true
sort: 5
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: url
table: visual_feedback
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: user_info_group
type: alias
meta:
collection: visual_feedback
field: user_info_group
group: null
hidden: false
interface: group-detail
options:
header_icon: person
header_text: User Information
sort: 6
special:
- alias
- no-data
- group
width: full
- collection: visual_feedback
field: user_name
type: string
meta:
collection: visual_feedback
field: user_name
group: user_info_group
hidden: false
interface: input
sort: 1
width: half
schema:
name: user_name
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: user_identity
type: string
meta:
collection: visual_feedback
field: user_identity
group: user_info_group
hidden: false
interface: input
readonly: true
sort: 2
width: half
schema:
name: user_identity
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: technical_details_group
type: alias
meta:
collection: visual_feedback
field: technical_details_group
group: null
hidden: false
interface: group-detail
options:
header_icon: psychology
header_text: Technical Context
sort: 7
special:
- alias
- no-data
- group
width: full
- collection: visual_feedback
field: selector
type: string
meta:
collection: visual_feedback
field: selector
group: technical_details_group
hidden: false
interface: input
readonly: true
sort: 1
width: full
schema:
name: selector
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: x
type: float
meta:
collection: visual_feedback
field: x
group: technical_details_group
hidden: false
interface: input
sort: 2
width: half
schema:
name: x
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: 'y'
type: float
meta:
collection: visual_feedback
field: 'y'
group: technical_details_group
hidden: false
interface: input
sort: 3
width: half
schema:
name: 'y'
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: date_created
type: timestamp
meta:
collection: visual_feedback
conditions: null
display: datetime
display_options:
relative: true
field: date_created
group: null
hidden: false
interface: datetime
note: null
options: null
readonly: true
required: false
searchable: true
sort: 8
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: date_created
table: visual_feedback
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
is_nullable: true
- collection: visual_feedback_comments
field: id
type: uuid
meta:
collection: visual_feedback_comments
field: id
hidden: true
schema:
name: id
table: visual_feedback_comments
data_type: uuid
is_primary_key: true
- collection: visual_feedback_comments
field: feedback_id
type: uuid
meta:
collection: visual_feedback_comments
display: null
field: feedback_id
interface: select-relational
sort: 2
width: full
schema:
name: feedback_id
table: visual_feedback_comments
data_type: uuid
- collection: visual_feedback_comments
field: user_name
type: string
meta:
collection: visual_feedback_comments
field: user_name
interface: input
sort: 3
width: half
schema:
name: user_name
table: visual_feedback_comments
data_type: character varying
- collection: visual_feedback_comments
field: text
type: text
meta:
collection: visual_feedback_comments
field: text
interface: input-multiline
sort: 4
width: full
schema:
name: text
table: visual_feedback_comments
data_type: text
- collection: visual_feedback_comments
field: date_created
type: timestamp
meta:
collection: visual_feedback_comments
display: datetime
display_options:
relative: true
field: date_created
interface: datetime
readonly: true
sort: 5
width: full
schema:
name: date_created
table: visual_feedback_comments
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
systemFields:
- collection: directus_activity
field: timestamp
schema:
is_indexed: true
- collection: directus_revisions
field: activity
schema:
is_indexed: true
- collection: directus_revisions
field: parent
schema:
is_indexed: true
relations:
- collection: visual_feedback_comments
field: feedback_id
related_collection: visual_feedback
schema:
column: feedback_id
foreign_key_column: id
foreign_key_table: visual_feedback
table: visual_feedback_comments
meta:
junction_field: null
many_collection: visual_feedback_comments
many_field: feedback_id
one_allowed_m2m: false
one_collection: visual_feedback
one_deselect_action: nullify
one_field: null
sort_field: null

View File

@@ -1,36 +1,83 @@
services:
app:
klz-app:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install && npx next dev"
command: sh -c "npm install --legacy-peer-deps && npx next dev"
networks:
- default
- infra
volumes:
- .:/app
environment:
NODE_ENV: development
# Docker Internal Communication
DIRECTUS_URL: http://directus:8055
INTERNAL_DIRECTUS_URL: http://directus:8055
INFRA_DIRECTUS_URL: http://cms-infra-infra-cms-1:8055
GATEKEEPER_URL: http://gatekeeper:3000
DIRECTUS_API_TOKEN: ${DIRECTUS_API_TOKEN}
INFRA_DIRECTUS_TOKEN: ${INFRA_DIRECTUS_TOKEN}
NEXT_PUBLIC_FEEDBACK_ENABLED: ${NEXT_PUBLIC_FEEDBACK_ENABLED}
GATEKEEPER_BYPASS_ENABLED: ${GATEKEEPER_BYPASS_ENABLED}
ports:
- "3000:3000"
labels:
- "traefik.enable=true"
# Clear all production-related TLS/Middleware settings for the main routers
- "traefik.http.routers.klz-cables.entrypoints=web"
- "traefik.http.routers.klz-cables.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-cables.tls=false"
- "traefik.http.routers.klz-cables.middlewares="
# Global local settings
- "traefik.http.routers.klz-cables-local.entrypoints=web"
- "traefik.http.routers.klz-cables-local.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-cables-local.tls=false"
- "traefik.http.routers.klz-cables-local.middlewares="
- "traefik.http.routers.klz-cables-local.service=klz-cables-local"
- "traefik.http.services.klz-cables-local.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
- "traefik.http.routers.klz-cables-web.entrypoints=web"
- "traefik.http.routers.klz-cables-web.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-cables-web.middlewares="
# Web direct router
- "traefik.http.routers.klz-cables-local-web.entrypoints=web"
- "traefik.http.routers.klz-cables-local-web.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-cables-local-web.tls=false"
- "traefik.http.routers.klz-cables-local-web.middlewares="
- "traefik.http.routers.klz-cables-local-web.service=klz-cables-local"
directus:
networks:
- default
- infra
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-cables-directus.entrypoints=web"
- "traefik.http.routers.klz-cables-directus.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.klz-cables-directus.tls=false"
- "traefik.http.routers.klz-cables-directus.middlewares="
- "traefik.http.routers.klz-cables-directus-local.entrypoints=web"
- "traefik.http.routers.klz-cables-directus-local.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.klz-cables-directus-local.tls=false"
- "traefik.http.routers.klz-cables-directus-local.middlewares="
- "traefik.http.routers.klz-cables-directus-local.service=klz-cables-directus-local"
- "traefik.http.services.klz-cables-directus-local.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
ports:
- "8055:8055"
- "${DIRECTUS_PORT:-8055}:8055"
environment:
PUBLIC_URL: http://cms.klz.localhost
gatekeeper:
image: node:20-alpine
working_dir: /app/packages/gatekeeper
command: sh -c "corepack enable && CI=true NPM_TOKEN=dummy pnpm install --no-frozen-lockfile && pnpm dev"
volumes:
- /Users/marcmintel/Projects/at-mintel:/app
networks:
- default
- infra
environment:
DIRECTUS_URL: http://directus:8055
NEXT_PUBLIC_BASE_URL: http://gatekeeper.klz.localhost
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
COOKIE_DOMAIN: localhost
NODE_ENV: development
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-cables-gatekeeper-local.entrypoints=web"
- "traefik.http.routers.klz-cables-gatekeeper-local.rule=Host(`gatekeeper.klz.localhost`)"
- "traefik.http.routers.klz-cables-gatekeeper-local.tls=false"
- "traefik.http.routers.klz-cables-gatekeeper-local.service=klz-cables-gatekeeper-local"
- "traefik.http.services.klz-cables-gatekeeper-local.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"

View File

@@ -70,7 +70,7 @@ services:
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:latest
image: registry.infra.mintel.me/mintel/gatekeeper:1.4.0
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
restart: always
networks:
@@ -84,6 +84,9 @@ services:
AUTH_COOKIE_NAME: klz_gatekeeper_session
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-klz-cables.com}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
DIRECTUS_URL: ${DIRECTUS_URL}
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
@@ -117,6 +120,8 @@ services:
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
- ./directus/migrations:/directus/migrations
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(`${DIRECTUS_HOST}`)"

View File

@@ -36,6 +36,19 @@ https://logs.infra.mintel.me
---
## Shared Image Optimization (imgproxy)
Alle Bilder werden zentral über **imgproxy** optimiert, resized und in moderne Formate (WebP, AVIF) konvertiert.
**Basis-URL**
https://img.infra.mintel.me
```text
https://img.infra.mintel.me/unsafe/rs:800x600/plain/https://example.com/bild.jpg
https://img.infra.mintel.me/rs:400x/plain/https://picsum.photos/2000/1333
---
## Production Platform (Alpha)
Alpha runs all customer websites and is publicly reachable.

View File

@@ -1,15 +1,14 @@
import {getRequestConfig} from 'next-intl/server';
import { getRequestConfig } from 'next-intl/server';
import * as Sentry from '@sentry/nextjs';
export default getRequestConfig(async ({requestLocale}) => {
// This typically corresponds to the `[locale]` segment
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Ensure that a valid locale is used
if (!locale || !['en', 'de'].includes(locale)) {
locale = 'en';
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
@@ -21,12 +20,12 @@ export default getRequestConfig(async ({requestLocale}) => {
}
Sentry.captureException(error);
},
getMessageFallback({namespace, key, error}) {
getMessageFallback({ namespace, key, error }) {
const path = [namespace, key].filter((part) => part != null).join('.');
if (error.code === 'MISSING_MESSAGE') {
return path;
}
return 'fallback';
}
};
} as any;
});

View File

@@ -22,6 +22,7 @@ function createConfig() {
isStaging: target === 'staging',
isTesting: target === 'testing',
isDevelopment: target === 'development',
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
baseUrl: env.NEXT_PUBLIC_BASE_URL,
@@ -67,6 +68,10 @@ function createConfig() {
internalUrl: env.INTERNAL_DIRECTUS_URL,
proxyPath: '/cms',
},
infraCMS: {
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL,
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN,
},
notifications: {
gotify: {
url: env.GOTIFY_URL,
@@ -135,6 +140,12 @@ export const config = {
get notifications() {
return getConfig().notifications;
},
get feedbackEnabled() {
return getConfig().feedbackEnabled;
},
get infraCMS() {
return getConfig().infraCMS;
},
};
/**

View File

@@ -1,4 +1,4 @@
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
import { createDirectus, rest, authentication, staticToken, readItems, readCollections } from '@directus/sdk';
import { config } from './config';
import { getServerAppServices } from './services/create-services.server';
@@ -13,6 +13,7 @@ const effectiveUrl =
? `${window.location.origin}${proxyPath}`
: proxyPath;
// Initialize client with authentication plugin
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
/**
@@ -30,20 +31,48 @@ function formatError(error: any) {
return 'A system error occurred. Our team has been notified.';
}
let authPromise: Promise<void> | null = null;
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
(client as any).setToken(token);
return;
}
// Check if we already have a valid session token in memory (for login flow)
const existingToken = await (client as any).getToken();
if (existingToken) {
return;
}
if (adminEmail && password) {
try {
await client.login(adminEmail, password);
} catch (e) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
console.error('Failed to authenticate with Directus:', e);
if (authPromise) {
return authPromise;
}
authPromise = (async () => {
try {
client.setToken(null as any);
await client.login(adminEmail, password);
console.log(`✅ Directus: Authenticated successfully as ${adminEmail}`);
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
console.error(`Failed to authenticate with Directus (${adminEmail}):`, e.message);
if (shouldShowDevErrors && e.errors) {
console.error('Directus Auth Details:', JSON.stringify(e.errors, null, 2));
}
// Clear the promise on failure (especially on invalid credentials)
// so we can retry on next request if credentials were updated
authPromise = null;
throw e;
}
})();
return authPromise;
} else if (shouldShowDevErrors && !adminEmail && !password && !token) {
console.warn('Directus: No token or admin credentials provided.');
}
}

View File

@@ -53,6 +53,21 @@ export const envSchema = z
// Gotify
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Gatekeeper
GATEKEEPER_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default('http://gatekeeper:3000'),
),
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false)
),
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false)
),
INFRA_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
INFRA_DIRECTUS_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
})
.superRefine((data, ctx) => {
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
@@ -100,5 +115,10 @@ export function getRawEnv() {
TARGET: process.env.TARGET,
GOTIFY_URL: process.env.GOTIFY_URL,
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
GATEKEEPER_URL: process.env.GATEKEEPER_URL,
NEXT_PUBLIC_FEEDBACK_ENABLED: process.env.NEXT_PUBLIC_FEEDBACK_ENABLED,
GATEKEEPER_BYPASS_ENABLED: process.env.GATEKEEPER_BYPASS_ENABLED,
INFRA_DIRECTUS_URL: process.env.INFRA_DIRECTUS_URL,
INFRA_DIRECTUS_TOKEN: process.env.INFRA_DIRECTUS_TOKEN,
};
}

View File

@@ -393,4 +393,4 @@
"cta": "Zurück zur Sicherheit"
}
}
}
}

View File

@@ -393,4 +393,4 @@
"cta": "Back to Safety"
}
}
}
}

3
next-env.d.ts vendored
View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

2123
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{
"dependencies": {
"@directus/sdk": "^18.0.3",
"@mintel/mail": "^1.2.3",
"@mintel/mail": "^1.5.0",
"@react-email/components": "^1.0.6",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^8.55.0",
"@sentry/nextjs": "^10.38.0",
"@swc/helpers": "^0.5.18",
"@types/cheerio": "^0.22.35",
"@types/leaflet": "^1.9.21",
@@ -17,16 +17,16 @@
"jsdom": "^27.4.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"next": "^14.2.35",
"next": "^16.1.6",
"next-i18next": "^15.4.3",
"next-intl": "^4.6.1",
"next-intl": "^4.8.2",
"next-mdx-remote": "^5.0.0",
"nodemailer": "^7.0.12",
"pdf-lib": "^1.17.1",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-email": "^5.2.5",
"react-leaflet": "^4.2.1",
"resend": "^3.5.0",
@@ -51,7 +51,7 @@
"@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.35",
"eslint-config-next": "^16.1.6",
"eslint-config-prettier": "^10.1.8",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
@@ -66,7 +66,7 @@
"name": "klz-cables-nextjs",
"private": true,
"scripts": {
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up klz-app directus directus-db",
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up klz-app directus directus-db gatekeeper",
"dev:local": "next dev",
"build": "next build",
"start": "next start",
@@ -81,12 +81,17 @@
"cms:bootstrap": "npm run cms:branding:local",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"cms:push:staging": "./scripts/sync-directus.sh push staging",
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
"cms:push:testing": "./scripts/sync-directus.sh push testing",
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:testing": "./scripts/cms-apply.sh testing",
"cms:schema:apply:staging": "./scripts/cms-apply.sh staging",
"cms:schema:apply:prod": "./scripts/cms-apply.sh production",
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
"cms:push:prod": "./scripts/sync-directus.sh push production",
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
"cms:pull:prod": "./scripts/sync-directus.sh pull production",
"cms:push:staging:DANGER": "./scripts/sync-directus.sh push staging",
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
"prepare": "husky"

View File

@@ -33,8 +33,8 @@ export default function middleware(request: NextRequest) {
const [publicHostname] = hostHeader.split(':');
urlObj.protocol = proto;
urlObj.hostname = publicHostname;
urlObj.port = ''; // Explicitly clear internal port (3000)
// urlObj.hostname = publicHostname; // Don't rewrite hostname yet as it breaks internal fetches in dev
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
effectiveRequest = new NextRequest(urlObj, {
headers: request.headers,

View File

@@ -0,0 +1,39 @@
import { createDirectus, rest, authentication, createPanel, readDashboards } from '@directus/sdk';
async function addStatusPanel() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log(`🚀 Adding Status Panel: ${url}`);
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
await client.login(email, password);
console.log('✅ Authenticated');
const dashboards = await client.request(readDashboards({ filter: { name: { _eq: 'Feedback Operational Intelligence' } } }));
const db = dashboards[0];
if (db) {
await client.request(createPanel({
dashboard: db.id,
name: 'Dashboard Status: LIVE',
type: 'label',
width: 24, height: 2, position_x: 0, position_y: 24,
options: {
text: '### ✅ Dashboard Rendering Service Active\n\nIf you see this, the system is online and updated as of ' + new Date().toISOString()
}
}));
console.log('✅ Status Panel Added');
} else {
console.error('❌ Dashboard not found');
}
} catch (e: any) {
console.error('❌ Failed:');
console.error(e.message);
}
}
addStatusPanel();

54
scripts/cms-apply.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/bin/bash
ENV=$1
REMOTE_HOST="root@alpha.mintel.me"
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
if [ -z "$ENV" ]; then
echo "Usage: ./scripts/cms-apply.sh [local|testing|staging|production]"
exit 1
fi
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
case $ENV in
local)
CONTAINER=$(docker compose ps -q directus)
if [ -z "$CONTAINER" ]; then
echo "❌ Local directus container not found."
exit 1
fi
echo "🚀 Applying schema locally..."
docker exec "$CONTAINER" npx directus schema apply /directus/schema/snapshot.yaml --yes
;;
testing|staging|production)
case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
production) PROJECT_NAME="${PRJ_ID}-prod" ;;
esac
echo "📤 Uploading snapshot to $ENV..."
scp ./directus/schema/snapshot.yaml "$REMOTE_HOST:$REMOTE_DIR/directus/schema/snapshot.yaml"
echo "🔍 Detecting remote container..."
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus")
if [ -z "$REMOTE_CONTAINER" ]; then
echo "❌ Remote container for $ENV not found."
exit 1
fi
echo "🚀 Applying schema to $ENV..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply /directus/schema/snapshot.yaml --yes"
echo "🔄 Restarting Directus to clear cache..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
;;
*)
echo "❌ Invalid environment."
exit 1
;;
esac
echo "✨ Schema apply complete!"

15
scripts/cms-snapshot.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Detect local container
LOCAL_CONTAINER=$(docker compose ps -q directus)
if [ -z "$LOCAL_CONTAINER" ]; then
echo "❌ Local directus container not found. Is it running?"
exit 1
fi
echo "📸 Creating schema snapshot..."
# Note: we save it to the mounted volume path inside the container
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot /directus/schema/snapshot.yaml
echo "✅ Snapshot saved to ./directus/schema/snapshot.yaml"

33
scripts/container-fix.js Normal file
View File

@@ -0,0 +1,33 @@
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database('/directus/database/data.db');
db.serialize(() => {
console.log('--- INTERNAL REPAIR START ---');
// 1. Grant to Public Policy
db.run(`INSERT INTO directus_permissions (collection, action, fields, permissions, validation, presets, policy)
VALUES ('visual_feedback', 'read', '["*"]', '{}', '{}', '{}', 'abf8a154-5b1c-4a46-ac9c-7300570f4f17')`, (err) => {
if (err) console.log('Public grant note:', err.message);
else console.log('✅ Public READ granted.');
});
// 2. Grant to Admin Policy
db.run(`INSERT INTO directus_permissions (collection, action, fields, permissions, validation, presets, policy)
VALUES ('visual_feedback', 'read', '["*"]', '{}', '{}', '{}', 'bed7c035-28f7-4a78-b11a-0dc0e7fc3cd4')`, (err) => {
if (err) console.log('Admin grant note:', err.message);
else console.log('✅ Admin READ granted.');
});
// 3. Mark collection as non-hidden
db.run(`UPDATE directus_collections SET hidden = 0, accountability = NULL WHERE collection = 'visual_feedback'`, (err) => {
if (err) console.log('Collection update error:', err.message);
else console.log('✅ Collection metadata cleared.');
});
db.all(`SELECT COUNT(*) as count FROM visual_feedback`, (err, rows) => {
if (err) console.log('Item count error:', err.message);
else console.log(`📊 Items in visual_feedback: ${rows[0].count}`);
});
});
db.close();

View File

@@ -0,0 +1,82 @@
import { createDirectus, rest, authentication, readDashboards, createDashboard, createPanel } from '@directus/sdk';
async function debugVariants() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log(`🚀 creating Debug Variants Dashboard: ${url}`);
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
await client.login(email, password);
console.log('✅ Authenticated');
const dashboard = await client.request(createDashboard({
name: 'Debug List Variants',
icon: 'bug_report',
color: '#FF0000'
}));
// Variant 1: No Template
await client.request(createPanel({
dashboard: dashboard.id,
name: 'No Template',
type: 'list',
width: 8, height: 8, position_x: 0, position_y: 0,
options: {
collection: 'visual_feedback',
fields: ['text'],
limit: 5
}
}));
// Variant 2: Simple Template {{text}}
await client.request(createPanel({
dashboard: dashboard.id,
name: 'Simple {{text}}',
type: 'list',
width: 8, height: 8, position_x: 8, position_y: 0,
options: {
collection: 'visual_feedback',
template: '{{text}}',
limit: 5
}
}));
// Variant 3: Spaced Template {{ text }}
await client.request(createPanel({
dashboard: dashboard.id,
name: 'Spaced {{ text }}',
type: 'list',
width: 8, height: 8, position_x: 16, position_y: 0,
options: {
collection: 'visual_feedback',
template: '{{ text }}',
limit: 5
}
}));
// Variant 4: With fields array AND template
await client.request(createPanel({
dashboard: dashboard.id,
name: 'Fields + Template',
type: 'list',
width: 8, height: 8, position_x: 0, position_y: 8,
options: {
collection: 'visual_feedback',
fields: ['text', 'user_name'],
template: '{{user_name}}: {{text}}',
limit: 5
}
}));
console.log('✅ Debug Dashboard Created');
} catch (e: any) {
console.error('❌ Creation failed:');
console.error(e.message);
}
}
debugVariants();

View File

@@ -0,0 +1,42 @@
import { createDirectus, rest, authentication, readDashboards, createDashboard, createPanel } from '@directus/sdk';
async function debugLabelFallback() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log(`🚀 creating Debug Label Fallback Dashboard: ${url}`);
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
await client.login(email, password);
console.log('✅ Authenticated');
const dashboard = await client.request(createDashboard({
name: 'Debug Label Fallback',
icon: 'label',
color: '#0000FF'
}));
// Variant 5: Label with Markdown (Static list simulation)
// Note: Label panels don't take a collection, they just render text.
// This confirms if we can at least show SOMETHING.
await client.request(createPanel({
dashboard: dashboard.id,
name: 'Label Fallback',
type: 'label',
width: 12, height: 10, position_x: 0, position_y: 0,
options: {
text: '### Recent Feedback\n\n- User: Test Message\n- User2: Another Message'
}
}));
console.log('✅ Debug Label Dashboard Created');
} catch (e: any) {
console.error('❌ Creation failed:');
console.error(e.message);
}
}
debugLabelFallback();

View File

@@ -0,0 +1,45 @@
import { createDirectus, rest, authentication, readDashboards, createPanel, readPanels } from '@directus/sdk';
async function createDefaultList() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log(`🚀 Creating Default List Panel: ${url}`);
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
await client.login(email, password);
console.log('✅ Authenticated');
const dashboards = await client.request(readDashboards({ filter: { name: { _eq: 'Feedback Operational Intelligence' } } }));
const db = dashboards[0];
// Create a completely default list panel
const panel = await client.request(createPanel({
dashboard: db.id,
name: 'Debug Default List',
type: 'list',
width: 12,
height: 10,
position_x: 0,
position_y: 24, // below
options: {
collection: 'visual_feedback'
}
}));
console.log(`Created Debug Panel: ${panel.id}`);
console.log(`Options: ${JSON.stringify(panel.options, null, 2)}`);
// Let's read it back to see if Directus enriched it with defaults
const panels = await client.request(readPanels({ filter: { id: { _eq: panel.id } } }));
console.log(`Enriched Options: ${JSON.stringify(panels[0].options, null, 2)}`);
} catch (e: any) {
console.error('❌ Creation failed:');
console.error(e.message);
}
}
createDefaultList();

34
scripts/feedback.yaml Normal file
View File

@@ -0,0 +1,34 @@
version: 1
directus: 11.14.1
vendor: sqlite
collections:
- collection: visual_feedback
meta:
icon: feedback
display_template: "{{user_name}}: {{text}}"
accountability: null
schema:
name: visual_feedback
fields:
- collection: visual_feedback
field: id
type: integer
schema:
is_primary_key: true
has_auto_increment: true
- collection: visual_feedback
field: status
type: string
schema:
default_value: open
- collection: visual_feedback
field: user_name
type: string
- collection: visual_feedback
field: text
type: text
- collection: visual_feedback
field: date_created
type: timestamp
schema:
default_value: CURRENT_TIMESTAMP

60
scripts/final-fix.ts Normal file
View File

@@ -0,0 +1,60 @@
import { createDirectus, rest, staticToken, updateCollection, createPermission, readCollections, readPermissions, createDashboard, createPanel, createItems } from '@directus/sdk';
import { config } from '../lib/config';
async function finalFix() {
const url = 'http://localhost:8059';
const token = '59fb8f4c1a51b18fe28ad947f713914e';
const client = createDirectus(url).with(staticToken(token)).with(rest());
try {
console.log('--- 1. UPDATE COLLECTION ACCOUNTABILITY ---');
await client.request(updateCollection('visual_feedback', {
meta: { accountability: null }
} as any));
console.log('✅ Accountability set to null.');
console.log('\n--- 2. GRANT PUBLIC READ ---');
// Policy ID for Public is always 'abf8a154-5b1c-4a46-ac9c-7300570f4f17' in v11 bootstrap usually,
// but let's check first.
try {
await client.request(createPermission({
policy: 'abf8a154-5b1c-4a46-ac9c-7300570f4f17',
collection: 'visual_feedback',
action: 'read',
fields: ['*']
} as any));
console.log('✅ Public READ granted.');
} catch (e) {
console.log(' (Public READ might already exist)');
}
console.log('\n--- 3. RECREATE DASHBOARD ---');
const dash = await client.request(createDashboard({
name: 'Feedback Final',
icon: 'check_circle',
color: '#00FF00'
}));
console.log(`✅ Dashboard "Feedback Final" ID: ${dash.id}`);
await client.request(createPanel({
dashboard: dash.id,
name: 'Visible Feedbacks',
type: 'metric',
width: 12,
height: 6,
position_x: 1,
position_y: 1,
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
} as any));
console.log('\n--- 4. VERIFY READ VIA TOKEN ---');
const items = await client.request(() => ({ path: '/items/visual_feedback', method: 'GET' }));
console.log(`✅ Items count via token: ${items.data.length}`);
} catch (e: any) {
console.error('❌ Final fix failed:', e);
if (e.errors) console.error(JSON.stringify(e.errors, null, 2));
}
}
finalFix();

View File

@@ -0,0 +1,45 @@
import { createDirectus, rest, authentication, readCollections, updateCollection } from '@directus/sdk';
async function checkCollectionConfig() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log(`🚀 Checking Collection Config: ${url}`);
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
await client.login(email, password);
console.log('✅ Authenticated');
const collection = await client.request(readCollections());
const fb = collection.find(c => c.collection === 'visual_feedback');
if (fb) {
console.log(`Collection: ${fb.collection}`);
console.log(`Display Template: ${fb.meta?.display_template}`);
console.log(`Hidden: ${fb.meta?.hidden}`);
if (!fb.meta?.display_template) {
console.log('⚠️ Display Template is missing! Fixing it...');
await client.request(updateCollection('visual_feedback', {
meta: {
...fb.meta,
display_template: '{{text}}' // Set a sensible default
}
}));
console.log('✅ Display Template set to {{text}}');
} else {
console.log('✅ Display Template is already set.');
}
} else {
console.error('❌ Collection visual_feedback not found!');
}
} catch (e: any) {
console.error('❌ Check failed:');
console.error(e.message);
}
}
checkCollectionConfig();

View File

@@ -0,0 +1,50 @@
import { createDirectus, rest, authentication, readDashboards, readPanels, updatePanel } from '@directus/sdk';
async function fixListPanel() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log(`🚀 Fixing List Panel Template: ${url}`);
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
await client.login(email, password);
console.log('✅ Authenticated');
const dashboards = await client.request(readDashboards({ filter: { name: { _eq: 'Feedback Operational Intelligence' } } }));
const db = dashboards[0];
if (!db) throw new Error('Dashboard not found');
const panels = await client.request(readPanels({
filter: { dashboard: { _eq: db.id }, type: { _eq: 'list' } }
}));
const listPanel = panels[0];
if (!listPanel) throw new Error('List panel not found');
console.log(`Found Panel: ${listPanel.id}`);
console.log(`Current Template: ${listPanel.options.template}`);
// Try a different syntax or simple field
// In some versions it's {{field}}, in others it might be just field field
// Let's try to set it to just {{text}} to see if basic interpolation works
// Or maybe it needs HTML?
console.log('Updating template to simple {{text}} ...');
await client.request(updatePanel(listPanel.id, {
options: {
...listPanel.options,
template: '{{text}}'
}
}));
console.log('✅ Panel updated');
} catch (e: any) {
console.error('❌ Fix failed:');
console.error(e.message);
}
}
fixListPanel();

View File

@@ -0,0 +1,38 @@
import { createDirectus, rest, authentication, readDashboards, readPanels } from '@directus/sdk';
async function inspectDashboards() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log(`🚀 Inspecting Dashboards: ${url}`);
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
await client.login(email, password);
console.log('✅ Authenticated');
const dashboards = await client.request(readDashboards({ fields: ['*'] }));
console.log('\n--- DASHBOARDS ---');
for (const db of dashboards) {
console.log(`Dashboard: ${db.name} (${db.id})`);
const panels = await client.request(readPanels({
filter: { dashboard: { _eq: db.id } },
fields: ['*']
}));
console.log(' Panels:');
panels.forEach(p => {
console.log(` - [${p.type}] ${p.name}`);
console.log(` Options: ${JSON.stringify(p.options, null, 2)}`);
});
}
} catch (e: any) {
console.error('❌ Inspection failed:');
console.error(e.message);
}
}
inspectDashboards();

80
scripts/nuke-pave.ts Normal file
View File

@@ -0,0 +1,80 @@
import { createDirectus, rest, staticToken, deleteCollection, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies } from '@directus/sdk';
import { config } from '../lib/config';
async function nukeAndPaveV11() {
console.log('🚀 NUKE & PAVE: Feedback System v11...');
const url = 'http://localhost:8059';
const token = '59fb8f4c1a51b18fe28ad947f713914e';
const client = createDirectus(url).with(staticToken(token)).with(rest());
try {
console.log('🗑️ Deleting collections...');
try { await client.request(deleteCollection('visual_feedback_comments')); } catch (e) { }
try { await client.request(deleteCollection('visual_feedback')); } catch (e) { }
console.log('🏗️ Creating "visual_feedback" fresh...');
await client.request(createCollection({
collection: 'visual_feedback',
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
fields: [
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
{ field: 'url', type: 'string', meta: { interface: 'input' } },
{ field: 'selector', type: 'string', meta: { interface: 'input' } },
{ field: 'text', type: 'text', meta: { interface: 'input-multiline' } },
{ field: 'user_name', type: 'string', meta: { interface: 'input' } },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' }, meta: { interface: 'datetime' } }
]
} as any));
console.log('🔐 Granting Permissions...');
const policies = await client.request(readPolicies());
const adminPolicy = policies.find(p => p.name === 'Administrator')?.id;
const publicPolicy = policies.find(p => p.name === '$t:public_label' || p.name === 'Public')?.id;
for (const policy of [adminPolicy, publicPolicy]) {
if (!policy) continue;
console.log(` - Granting to Policy: ${policy}...`);
await client.request(createPermission({
policy,
collection: 'visual_feedback',
action: 'read',
fields: ['*'],
permissions: {},
validation: {}
} as any));
}
console.log('💉 Injecting items...');
await client.request(createItems('visual_feedback', [
{ user_name: 'Antigravity', text: 'Nuke & Pave Success', status: 'open' }
]));
console.log('📊 Recreating Dashboard...');
const dash = await client.request(createDashboard({
name: 'Feedback Insights',
icon: 'analytics',
color: '#6644FF'
}));
await client.request(createPanel({
dashboard: dash.id,
name: 'Status',
type: 'metric',
width: 12,
height: 6,
position_x: 1,
position_y: 1,
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
} as any));
console.log('✅ Nuke & Pave Complete!');
} catch (e: any) {
console.error('❌ Nuke failed:', e);
if (e.errors) console.error(JSON.stringify(e.errors, null, 2));
}
}
nukeAndPaveV11();

View File

@@ -0,0 +1,176 @@
import { createDirectus, rest, authentication, readDashboards, deleteDashboard, createDashboard, createPanel } from '@directus/sdk';
async function rebuildDashboards() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log(`🚀 Rebuilding Dashboards: ${url}`);
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
await client.login(email, password);
console.log('✅ Authenticated');
// 1. Delete existing dashboard
const oldDashboards = await client.request(readDashboards());
for (const db of oldDashboards) {
console.log(`Deleting dashboard: ${db.name} (${db.id})`);
await client.request(deleteDashboard(db.id));
}
// 2. Create the "Intelligence" Dashboard
const dashboard = await client.request(createDashboard({
name: 'Feedback Operational Intelligence',
note: 'High-fidelity overview of user feedback and system status.',
icon: 'analytics',
color: '#000000'
}));
console.log(`Created Dashboard: ${dashboard.id}`);
// 3. Add Panels (Grid is 24 units wide)
// --- Row 1: Key Metrics ---
// Total
await client.request(createPanel({
dashboard: dashboard.id as string,
name: 'Total Submissions',
type: 'metric',
width: 6,
height: 4,
position_x: 0,
position_y: 0,
options: {
collection: 'visual_feedback',
function: 'count',
field: 'id',
color: '#666666',
icon: 'all_inbox'
}
}));
// Open
await client.request(createPanel({
dashboard: dashboard.id as string,
name: 'Pending Action',
type: 'metric',
width: 6,
height: 4,
position_x: 6,
position_y: 0,
options: {
collection: 'visual_feedback',
function: 'count',
field: 'id',
filter: { status: { _eq: 'open' } },
color: '#FF0000',
icon: 'warning'
}
}));
// Type: Bug
await client.request(createPanel({
dashboard: dashboard.id as string,
name: 'Bugs Reported',
type: 'metric',
width: 6,
height: 4,
position_x: 12,
position_y: 0,
options: {
collection: 'visual_feedback',
function: 'count',
field: 'id',
filter: { type: { _eq: 'bug' } },
color: '#E91E63',
icon: 'bug_report'
}
}));
// Type: Feature
await client.request(createPanel({
dashboard: dashboard.id as string,
name: 'Feature Requests',
type: 'metric',
width: 6,
height: 4,
position_x: 18,
position_y: 0,
options: {
collection: 'visual_feedback',
function: 'count',
field: 'id',
filter: { type: { _eq: 'feature' } },
color: '#4CAF50',
icon: 'lightbulb'
}
}));
// --- Row 2: Trends and Distribution ---
// Time series (Volume)
await client.request(createPanel({
dashboard: dashboard.id as string,
name: 'Feedback Volume (Last 30 Days)',
type: 'chart-timeseries',
width: 16,
height: 10,
position_x: 0,
position_y: 4,
options: {
collection: 'visual_feedback',
function: 'count',
field: 'id',
group: 'date_created',
interval: 'day',
show_marker: true,
color: '#000000'
}
}));
// Category distribution (Pie)
await client.request(createPanel({
dashboard: dashboard.id as string,
name: 'Type Distribution',
type: 'chart-pie',
width: 8,
height: 10,
position_x: 16,
position_y: 4,
options: {
collection: 'visual_feedback',
function: 'count',
field: 'id',
group: 'type',
donut: true,
show_labels: true
}
}));
// --- Row 3: Details ---
// Detailed List
await client.request(createPanel({
dashboard: dashboard.id as string,
name: 'Recent Feedback (High Priority)',
type: 'list',
width: 24,
height: 10,
position_x: 0,
position_y: 14,
options: {
collection: 'visual_feedback',
fields: ['user_name', 'type', 'text', 'status', 'date_created'],
sort: ['-date_created'],
limit: 10
}
}));
console.log('✅ Dashboard rebuilt successfully');
} catch (e: any) {
console.error('❌ Rebuild failed:');
console.error(e.message);
if (e.errors) console.error(JSON.stringify(e.errors, null, 2));
}
}
rebuildDashboards();

View File

@@ -0,0 +1,122 @@
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
async function setupHardened() {
const url = 'http://localhost:8059';
const email = 'marc@mintel.me';
const password = 'Tim300493.';
console.log('🚀 v11 HARDENED SETUP START...');
const client = createDirectus(url)
.with(authentication('json'))
.with(rest());
try {
console.log('🔑 Authenticating...');
await client.login(email, password);
console.log('👤 Identifying IDs...');
const me = await client.request(readUsers({ filter: { email: { _eq: email } } }));
const adminUser = me[0];
const roles = await client.request(readRoles());
const adminRole = roles.find(r => r.name === 'Administrator');
const policies = await client.request(readPolicies());
const adminPolicy = policies.find(p => p.name === 'Administrator');
console.log(`- User: ${adminUser.id}`);
console.log(`- Role: ${adminRole?.id}`);
console.log(`- Policy: ${adminPolicy?.id}`);
if (adminPolicy && adminRole) {
console.log('🔗 Linking Role -> Policy...');
try {
await client.request(() => ({
path: '/access',
method: 'POST',
body: JSON.stringify({ role: adminRole.id, policy: adminPolicy.id })
}));
} catch (e) { }
console.log('🔗 Linking User -> Policy (individual)...');
try {
await client.request(() => ({
path: '/access',
method: 'POST',
body: JSON.stringify({ user: adminUser.id, policy: adminPolicy.id })
}));
} catch (e) { }
}
console.log('🏗️ Creating Collection "visual_feedback"...');
try {
await client.request(createCollection({
collection: 'visual_feedback',
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
fields: [
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
{ field: 'url', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'user_name', type: 'string' },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (e) {
console.log(' (Collection might already exist)');
}
if (adminPolicy) {
console.log('🔐 Granting ALL permissions to Administrator Policy...');
for (const action of ['create', 'read', 'update', 'delete']) {
try {
await client.request(createPermission({
collection: 'visual_feedback',
action,
fields: ['*'],
policy: adminPolicy.id
} as any));
} catch (e) { }
}
}
console.log('💉 Injecting Demo Item...');
try {
await client.request(createItems('visual_feedback', [
{ user_name: 'Antigravity', text: 'v11 Recovery Successful', status: 'open' }
]));
} catch (e) { }
console.log('📊 Recreating Dashboard...');
const dash = await client.request(createDashboard({
name: 'Feedback Final',
icon: 'check_circle',
color: '#00FF00'
}));
await client.request(createPanel({
dashboard: dash.id,
name: 'Total Feedbacks',
type: 'metric',
width: 12,
height: 6,
position_x: 1,
position_y: 1,
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
} as any));
console.log('✅ Setup Complete! Setting static token...');
await client.request(() => ({
path: `/users/${adminUser.id}`,
method: 'PATCH',
body: JSON.stringify({ token: '59fb8f4c1a51b18fe28ad947f713914e' })
}));
console.log('✨ ALL DONE.');
} catch (e: any) {
console.error('❌ CRITICAL FAILURE:', e);
if (e.errors) console.error(JSON.stringify(e.errors, null, 2));
}
}
setupHardened();

86
scripts/setup-feedback.ts Normal file
View File

@@ -0,0 +1,86 @@
import { createDirectus, rest, staticToken, createCollection, readCollections, createDashboard, createPanel, createItems, readDashboards, readPanels, createPermission, readPolicies } from '@directus/sdk';
import { config } from '../lib/config';
async function setupInfraFeedback() {
console.log('🚀 Setting up INFRA_FEEDBACK (Renamed for v11 Visibility)...');
const url = 'http://localhost:8059';
const token = '59fb8f4c1a51b18fe28ad947f713914e';
const client = createDirectus(url).with(staticToken(token)).with(rest());
try {
const collections = await client.request(readCollections());
const existing = collections.map(c => c.collection);
const COLL = 'infra_feedback';
if (!existing.includes(COLL)) {
console.log(`🏗️ Creating "${COLL}"...`);
await client.request(createCollection({
collection: COLL,
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
fields: [
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
{ field: 'url', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'user_name', type: 'string' },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
}
const policies = await client.request(readPolicies());
const adminPolicy = policies.find(p => p.name === 'Administrator')?.id;
const publicPolicy = policies.find(p => p.name === '$t:public_label' || p.name === 'Public')?.id;
for (const policy of [adminPolicy, publicPolicy]) {
if (!policy) continue;
console.log(`🔐 Granting permissions to ${policy}...`);
for (const action of ['create', 'read', 'update', 'delete']) {
try {
await client.request(createPermission({
policy,
collection: COLL,
action,
fields: ['*']
} as any));
} catch (e) { }
}
}
console.log('💉 Injecting test data...');
await client.request(createItems(COLL, [
{ user_name: 'Antigravity', text: 'Rename Test Success', status: 'open' }
]));
console.log('📊 Configuring Dashboard "Feedback OVERVIEW"...');
const dashboards = await client.request(readDashboards());
let dash = dashboards.find(d => d.name === 'Feedback OVERVIEW');
if (dash) await client.request(() => ({ path: `/dashboards/${dash.id}`, method: 'DELETE' }));
dash = await client.request(createDashboard({
name: 'Feedback OVERVIEW',
icon: 'visibility',
color: '#FFCC00'
}));
await client.request(createPanel({
dashboard: dash.id,
name: 'Table View',
type: 'list',
width: 24,
height: 12,
position_x: 1,
position_y: 1,
options: { collection: COLL, display_template: '{{user_name}}: {{text}}' }
} as any));
console.log('✅ Renamed Setup Complete! Dash: "Feedback OVERVIEW"');
} catch (error: any) {
console.error('❌ Rename setup failed:', error);
}
}
setupInfraFeedback();

229
scripts/test-auth.ts Normal file
View File

@@ -0,0 +1,229 @@
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
import { config } from './config';
import { getServerAppServices } from './services/create-services.server';
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
// Use internal URL if on server to bypass Gatekeeper/Auth
// Use proxy path in browser to stay on the same origin
const effectiveUrl =
typeof window === 'undefined'
? internalUrl || url
: typeof window !== 'undefined'
? `${window.location.origin}${proxyPath}`
: proxyPath;
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
/**
* Helper to determine if we should show detailed errors
*/
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
/**
* Genericizes error messages for production/staging
*/
function formatError(error: any) {
if (shouldShowDevErrors) {
return error.errors?.[0]?.message || error.message || 'An unexpected error occurred.';
}
return 'A system error occurred. Our team has been notified.';
}
let authPromise: Promise<void> | null = null;
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
return;
}
// Check if we already have a valid session token in memory
const existingToken = await client.getToken();
if (existingToken) {
return;
}
if (adminEmail && password) {
if (authPromise) {
return authPromise;
}
authPromise = (async () => {
try {
client.setToken(null as any);
await client.login(adminEmail, password);
console.log(`✅ Directus: Authenticated successfully as ${adminEmail}`);
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
console.error(`Failed to authenticate with Directus (${adminEmail}):`, e.message);
if (shouldShowDevErrors && e.errors) {
console.error('Directus Auth Details:', JSON.stringify(e.errors, null, 2));
}
// Clear the promise on failure (especially on invalid credentials)
// so we can retry on next request if credentials were updated
authPromise = null;
throw e;
}
})();
return authPromise;
} else if (shouldShowDevErrors && !adminEmail && !password && !token) {
console.warn('Directus: No token or admin credentials provided.');
}
}
/**
* Maps the new translation-based schema back to the application's Product interface
*/
function mapDirectusProduct(item: any, locale: string): any {
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
const translation =
item.translations?.find((t: any) => t.languages_code === langCode) ||
item.translations?.[0] ||
{};
return {
id: item.id,
sku: item.sku,
title: translation.name || '',
description: translation.description || '',
content: translation.content || '',
technicalData: {
technicalItems: translation.technical_items || [],
voltageTables: translation.voltage_tables || [],
},
locale: locale,
// Use proxy URL for assets to avoid CORS and handle internal/external issues
data_sheet_url: item.data_sheet ? `${proxyPath}/assets/${item.data_sheet}` : null,
categories: (item.categories_link || [])
.map((c: any) => c.categories_id?.translations?.[0]?.name)
.filter(Boolean),
};
}
export async function getProducts(locale: string = 'de') {
await ensureAuthenticated();
try {
const items = await client.request(
readItems('products', {
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
}),
);
return items.map((item) => mapDirectusProduct(item, locale));
} catch (error) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
}
console.error('Error fetching products:', error);
return [];
}
}
export async function getProductBySlug(slug: string, locale: string = 'de') {
await ensureAuthenticated();
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
try {
const items = await client.request(
readItems('products', {
filter: {
translations: {
slug: { _eq: slug },
languages_code: { _eq: langCode },
},
},
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
limit: 1,
}),
);
if (!items || items.length === 0) return null;
return mapDirectusProduct(items[0], locale);
} catch (error) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, {
part: 'directus_get_product_by_slug',
slug,
});
}
console.error(`Error fetching product ${slug}:`, error);
return null;
}
}
export async function checkHealth() {
try {
// 1. Connectivity & Auth Check
try {
await ensureAuthenticated();
await client.request(readCollections());
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
}
console.error('Directus authentication failed during health check:', e);
return {
status: 'error',
message: shouldShowDevErrors
? 'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.'
: 'CMS is currently unavailable due to an internal authentication error.',
code: 'AUTH_FAILED',
details: shouldShowDevErrors ? e.message : undefined,
};
}
// 2. Schema check (does the contact_submissions table exist?)
try {
await client.request(readItems('contact_submissions', { limit: 1 }));
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
}
if (
e.message?.includes('does not exist') ||
e.code === 'INVALID_PAYLOAD' ||
e.status === 404
) {
return {
status: 'error',
message: shouldShowDevErrors
? `The "contact_submissions" collection is missing or inaccessible. Error: ${e.message || 'Unknown'}`
: 'Required data structures are currently unavailable.',
code: 'SCHEMA_MISSING',
};
}
return {
status: 'error',
message: shouldShowDevErrors
? `Schema error: ${e.errors?.[0]?.message || e.message || 'Unknown error'}`
: 'The data schema is currently misconfigured.',
code: 'SCHEMA_ERROR',
};
}
return { status: 'ok', message: 'Directus is reachable and responding.' };
} catch (error: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, { part: 'directus_health_critical' });
}
console.error('Directus health check failed with unexpected error:', error);
return {
status: 'error',
message: formatError(error),
code: error.code || 'UNKNOWN',
};
}
}
export default client;
(async () => {
try {
await ensureAuthenticated();
console.log('Auth test successful');
} catch (e) {
console.error('Auth test failed:', e.message);
}
})();

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -20,12 +24,30 @@
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"lib/*": ["./lib/*"],
"components/*": ["./components/*"],
"data/*": ["./data/*"]
"@/*": [
"./*"
],
"lib/*": [
"./lib/*"
],
"components/*": [
"./components/*"
],
"data/*": [
"./data/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tests/**/*.test.ts"],
"exclude": ["node_modules", "scripts"]
}
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"tests/**/*.test.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"scripts"
]
}