feat: integrate feedback module
This commit is contained in:
12
.env
12
.env
@@ -2,15 +2,9 @@
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
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
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=true
|
||||||
# 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"
|
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
@@ -26,11 +20,15 @@ DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
|||||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
||||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||||
|
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
DIRECTUS_DB_NAME=directus
|
DIRECTUS_DB_NAME=directus
|
||||||
DIRECTUS_DB_USER=directus
|
DIRECTUS_DB_USER=directus
|
||||||
# Local Development
|
# Local Development
|
||||||
PROJECT_NAME=klz-cables
|
PROJECT_NAME=klz-cables
|
||||||
|
GATEKEEPER_BYPASS_ENABLED=true
|
||||||
TRAEFIK_HOST=klz.localhost
|
TRAEFIK_HOST=klz.localhost
|
||||||
DIRECTUS_HOST=cms.klz.localhost
|
DIRECTUS_HOST=cms.klz.localhost
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
COOKIE_DOMAIN=localhost
|
COOKIE_DOMAIN=localhost
|
||||||
|
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||||
|
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
DIRECTUS_PORT=8055
|
||||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||||
NEXT_PUBLIC_TARGET=development
|
|
||||||
# TARGET is used server-side
|
|
||||||
TARGET=development
|
TARGET=development
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
|
|||||||
@@ -327,7 +327,9 @@ jobs:
|
|||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
||||||
set -e
|
set -e
|
||||||
mkdir -p /home/deploy/sites/klz-cables.com/varnish
|
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
|
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
|
||||||
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
|
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
|
||||||
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
|
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
|
||||||
@@ -338,6 +340,7 @@ jobs:
|
|||||||
# 2. Transfer files
|
# 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 /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 -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/
|
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'
|
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
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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..."
|
echo "→ Verifying Varnish Backend Health..."
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list
|
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
|
||||||
directus/uploads
|
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';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
@@ -29,7 +29,8 @@ export async function generateStaticParams() {
|
|||||||
return params;
|
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);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) return {};
|
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 pageData = await getPageBySlug(slug, locale);
|
||||||
const t = await getTranslations('StandardPage');
|
const t = await getTranslations('StandardPage');
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ import { Heading } from '@/components/ui';
|
|||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
||||||
params: { locale, slug },
|
const { locale, slug } = await params;
|
||||||
}: BlogPostProps): Promise<Metadata> {
|
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) return {};
|
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 post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
const { prev, next } = await getAdjacentPosts(slug, locale);
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { getOGImageMetadata } from '@/lib/metadata';
|
|||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
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' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
return {
|
return {
|
||||||
title: t('title'),
|
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 t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Header from '@/components/Header';
|
|||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
|
import { FeedbackOverlay } from '@/components/feedback/FeedbackOverlay';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
@@ -32,27 +33,38 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params: { locale },
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Providing all messages to the client
|
const { locale } = await params;
|
||||||
// side is the easiest way to get started
|
|
||||||
const messages = await getMessages();
|
// 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 (
|
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">
|
<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 />
|
<JsonLd />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
<CMSConnectivityNotice />
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
{/* Sends pageviews for client-side navigations */}
|
|
||||||
<AnalyticsProvider />
|
<AnalyticsProvider />
|
||||||
|
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
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 (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -55,10 +60,11 @@ export default function HomePage({ params: { locale } }: { params: { locale: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params: { locale },
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
// Use translations for meta where available (namespace: Index.meta)
|
// Use translations for meta where available (namespace: Index.meta)
|
||||||
// Fallback to a sensible default if translation keys are missing.
|
// Fallback to a sensible default if translation keys are missing.
|
||||||
let t;
|
let t;
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import Reveal from '@/components/Reveal';
|
|||||||
import Gallery from '@/components/team/Gallery';
|
import Gallery from '@/components/team/Gallery';
|
||||||
|
|
||||||
interface TeamPageProps {
|
interface TeamPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
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 t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
const title = t('meta.title') || t('hero.subtitle');
|
const title = t('meta.title') || t('hero.subtitle');
|
||||||
const description = t('meta.description') || t('hero.title');
|
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' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
|
||||||
return (
|
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 />
|
<HeroIllustration />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
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:
|
services:
|
||||||
app:
|
klz-app:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
working_dir: /app
|
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:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
# Docker Internal Communication
|
# Docker Internal Communication
|
||||||
DIRECTUS_URL: http://directus:8055
|
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:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# Clear all production-related TLS/Middleware settings for the main routers
|
# Global local settings
|
||||||
- "traefik.http.routers.klz-cables.entrypoints=web"
|
- "traefik.http.routers.klz-cables-local.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-cables.rule=Host(`klz.localhost`)"
|
- "traefik.http.routers.klz-cables-local.rule=Host(`klz.localhost`)"
|
||||||
- "traefik.http.routers.klz-cables.tls=false"
|
- "traefik.http.routers.klz-cables-local.tls=false"
|
||||||
- "traefik.http.routers.klz-cables.middlewares="
|
- "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"
|
# Web direct router
|
||||||
- "traefik.http.routers.klz-cables-web.rule=Host(`klz.localhost`)"
|
- "traefik.http.routers.klz-cables-local-web.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-cables-web.middlewares="
|
- "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:
|
directus:
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.klz-cables-directus.entrypoints=web"
|
- "traefik.http.routers.klz-cables-directus-local.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-cables-directus.rule=Host(`cms.klz.localhost`)"
|
- "traefik.http.routers.klz-cables-directus-local.rule=Host(`cms.klz.localhost`)"
|
||||||
- "traefik.http.routers.klz-cables-directus.tls=false"
|
- "traefik.http.routers.klz-cables-directus-local.tls=false"
|
||||||
- "traefik.http.routers.klz-cables-directus.middlewares="
|
- "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:
|
ports:
|
||||||
- "8055:8055"
|
- "${DIRECTUS_PORT:-8055}:8055"
|
||||||
environment:
|
environment:
|
||||||
PUBLIC_URL: http://cms.klz.localhost
|
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"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
|
|
||||||
gatekeeper:
|
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
|
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
@@ -84,6 +84,9 @@ services:
|
|||||||
AUTH_COOKIE_NAME: klz_gatekeeper_session
|
AUTH_COOKIE_NAME: klz_gatekeeper_session
|
||||||
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-klz-cables.com}
|
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-klz-cables.com}
|
||||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
|
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
|
||||||
|
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||||
|
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
||||||
@@ -117,6 +120,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./directus/uploads:/directus/uploads
|
- ./directus/uploads:/directus/uploads
|
||||||
- ./directus/extensions:/directus/extensions
|
- ./directus/extensions:/directus/extensions
|
||||||
|
- ./directus/schema:/directus/schema
|
||||||
|
- ./directus/migrations:/directus/migrations
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(`${DIRECTUS_HOST}`)"
|
- "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)
|
## Production Platform (Alpha)
|
||||||
|
|
||||||
Alpha runs all customer websites and is publicly reachable.
|
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';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
export default getRequestConfig(async ({requestLocale}) => {
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
// This typically corresponds to the `[locale]` segment
|
|
||||||
let locale = await requestLocale;
|
let locale = await requestLocale;
|
||||||
|
|
||||||
// Ensure that a valid locale is used
|
// Ensure that a valid locale is used
|
||||||
if (!locale || !['en', 'de'].includes(locale)) {
|
if (!locale || !['en', 'de'].includes(locale)) {
|
||||||
locale = 'en';
|
locale = 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
messages: (await import(`../messages/${locale}.json`)).default,
|
messages: (await import(`../messages/${locale}.json`)).default,
|
||||||
@@ -21,12 +20,12 @@ export default getRequestConfig(async ({requestLocale}) => {
|
|||||||
}
|
}
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
},
|
},
|
||||||
getMessageFallback({namespace, key, error}) {
|
getMessageFallback({ namespace, key, error }) {
|
||||||
const path = [namespace, key].filter((part) => part != null).join('.');
|
const path = [namespace, key].filter((part) => part != null).join('.');
|
||||||
if (error.code === 'MISSING_MESSAGE') {
|
if (error.code === 'MISSING_MESSAGE') {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
return 'fallback';
|
return 'fallback';
|
||||||
}
|
}
|
||||||
};
|
} as any;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function createConfig() {
|
|||||||
isStaging: target === 'staging',
|
isStaging: target === 'staging',
|
||||||
isTesting: target === 'testing',
|
isTesting: target === 'testing',
|
||||||
isDevelopment: target === 'development',
|
isDevelopment: target === 'development',
|
||||||
|
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
|
|
||||||
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
||||||
|
|
||||||
@@ -67,6 +68,10 @@ function createConfig() {
|
|||||||
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
||||||
proxyPath: '/cms',
|
proxyPath: '/cms',
|
||||||
},
|
},
|
||||||
|
infraCMS: {
|
||||||
|
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL,
|
||||||
|
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN,
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
gotify: {
|
gotify: {
|
||||||
url: env.GOTIFY_URL,
|
url: env.GOTIFY_URL,
|
||||||
@@ -135,6 +140,12 @@ export const config = {
|
|||||||
get notifications() {
|
get notifications() {
|
||||||
return getConfig().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 { config } from './config';
|
||||||
import { getServerAppServices } from './services/create-services.server';
|
import { getServerAppServices } from './services/create-services.server';
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ const effectiveUrl =
|
|||||||
? `${window.location.origin}${proxyPath}`
|
? `${window.location.origin}${proxyPath}`
|
||||||
: proxyPath;
|
: proxyPath;
|
||||||
|
|
||||||
|
// Initialize client with authentication plugin
|
||||||
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
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.';
|
return 'A system error occurred. Our team has been notified.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let authPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export async function ensureAuthenticated() {
|
export async function ensureAuthenticated() {
|
||||||
if (token) {
|
if (token) {
|
||||||
client.setToken(token);
|
(client as any).setToken(token);
|
||||||
return;
|
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) {
|
if (adminEmail && password) {
|
||||||
try {
|
if (authPromise) {
|
||||||
await client.login(adminEmail, password);
|
return authPromise;
|
||||||
} catch (e) {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
|
||||||
}
|
|
||||||
console.error('Failed to authenticate with Directus:', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().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) => {
|
.superRefine((data, ctx) => {
|
||||||
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
||||||
@@ -100,5 +115,10 @@ export function getRawEnv() {
|
|||||||
TARGET: process.env.TARGET,
|
TARGET: process.env.TARGET,
|
||||||
GOTIFY_URL: process.env.GOTIFY_URL,
|
GOTIFY_URL: process.env.GOTIFY_URL,
|
||||||
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
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"
|
"cta": "Zurück zur Sicherheit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,4 +393,4 @@
|
|||||||
"cta": "Back to Safety"
|
"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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// 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": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^18.0.3",
|
"@directus/sdk": "^18.0.3",
|
||||||
"@mintel/mail": "^1.2.3",
|
"@mintel/mail": "^1.5.0",
|
||||||
"@react-email/components": "^1.0.6",
|
"@react-email/components": "^1.0.6",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@sentry/nextjs": "^8.55.0",
|
"@sentry/nextjs": "^10.38.0",
|
||||||
"@swc/helpers": "^0.5.18",
|
"@swc/helpers": "^0.5.18",
|
||||||
"@types/cheerio": "^0.22.35",
|
"@types/cheerio": "^0.22.35",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
@@ -17,16 +17,16 @@
|
|||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^14.2.35",
|
"next": "^16.1.6",
|
||||||
"next-i18next": "^15.4.3",
|
"next-i18next": "^15.4.3",
|
||||||
"next-intl": "^4.6.1",
|
"next-intl": "^4.8.2",
|
||||||
"next-mdx-remote": "^5.0.0",
|
"next-mdx-remote": "^5.0.0",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pino": "^10.3.0",
|
"pino": "^10.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.4",
|
||||||
"react-email": "^5.2.5",
|
"react-email": "^5.2.5",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"resend": "^3.5.0",
|
"resend": "^3.5.0",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "14.2.35",
|
"eslint-config-next": "^16.1.6",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"name": "klz-cables-nextjs",
|
"name": "klz-cables-nextjs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"dev:local": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -81,12 +81,17 @@
|
|||||||
"cms:bootstrap": "npm run cms:branding:local",
|
"cms:bootstrap": "npm run cms:branding:local",
|
||||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||||
"cms:push:staging": "./scripts/sync-directus.sh push staging",
|
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||||
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
|
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||||
"cms:push:testing": "./scripts/sync-directus.sh push testing",
|
"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: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: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: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')))\"",
|
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export default function middleware(request: NextRequest) {
|
|||||||
const [publicHostname] = hostHeader.split(':');
|
const [publicHostname] = hostHeader.split(':');
|
||||||
|
|
||||||
urlObj.protocol = proto;
|
urlObj.protocol = proto;
|
||||||
urlObj.hostname = publicHostname;
|
// urlObj.hostname = publicHostname; // Don't rewrite hostname yet as it breaks internal fetches in dev
|
||||||
urlObj.port = ''; // Explicitly clear internal port (3000)
|
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
|
||||||
|
|
||||||
effectiveRequest = new NextRequest(urlObj, {
|
effectiveRequest = new NextRequest(urlObj, {
|
||||||
headers: request.headers,
|
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": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": false,
|
"strict": false,
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -20,12 +24,30 @@
|
|||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"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"],
|
"include": [
|
||||||
"exclude": ["node_modules", "scripts"]
|
"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