feat: integrate feedback module
This commit is contained in:
12
.env
12
.env
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
4
.gitignore
vendored
@@ -4,4 +4,6 @@ node_modules
|
||||
|
||||
# Directus
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
!directus/extensions/
|
||||
!directus/schema/
|
||||
!directus/migrations/
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
79
app/api/feedback/route.ts
Normal 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
43
app/api/whoami/route.ts
Normal 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)' });
|
||||
}
|
||||
}
|
||||
539
components/feedback/FeedbackOverlay.tsx
Normal file
539
components/feedback/FeedbackOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
0
directus/migrations/.keep
Normal file
0
directus/migrations/.keep
Normal file
590
directus/schema/feedback_schema.yaml
Normal file
590
directus/schema/feedback_schema.yaml
Normal 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
|
||||
590
directus/schema/snapshot.yaml
Normal file
590
directus/schema/snapshot.yaml
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`)"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
lib/env.ts
20
lib/env.ts
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,4 +393,4 @@
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,4 +393,4 @@
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
next-env.d.ts
vendored
3
next-env.d.ts
vendored
@@ -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
2123
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
39
scripts/add-status-panel.ts
Normal file
39
scripts/add-status-panel.ts
Normal 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
54
scripts/cms-apply.sh
Executable 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
15
scripts/cms-snapshot.sh
Executable 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
33
scripts/container-fix.js
Normal 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();
|
||||
82
scripts/debug-dashboard-variants.ts
Normal file
82
scripts/debug-dashboard-variants.ts
Normal 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();
|
||||
42
scripts/debug-label-fallback.ts
Normal file
42
scripts/debug-label-fallback.ts
Normal 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();
|
||||
45
scripts/debug-list-defaults.ts
Normal file
45
scripts/debug-list-defaults.ts
Normal 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
34
scripts/feedback.yaml
Normal 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
60
scripts/final-fix.ts
Normal 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();
|
||||
45
scripts/fix-collection-display.ts
Normal file
45
scripts/fix-collection-display.ts
Normal 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();
|
||||
50
scripts/fix-list-template.ts
Normal file
50
scripts/fix-list-template.ts
Normal 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();
|
||||
38
scripts/inspect-dashboards.ts
Normal file
38
scripts/inspect-dashboards.ts
Normal 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
80
scripts/nuke-pave.ts
Normal 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();
|
||||
176
scripts/rebuild-dashboards.ts
Normal file
176
scripts/rebuild-dashboards.ts
Normal 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();
|
||||
122
scripts/setup-feedback-hardened.ts
Normal file
122
scripts/setup-feedback-hardened.ts
Normal 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
86
scripts/setup-feedback.ts
Normal 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
229
scripts/test-auth.ts
Normal 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);
|
||||
}
|
||||
})();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user