Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa81ea1ac9 |
@@ -46,5 +46,9 @@ next-env.d.ts
|
|||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
/src/app/sponsors
|
/src/app/sponsors
|
||||||
|
|
||||||
|
# Cloudflare / OpenNext
|
||||||
|
.open-next/
|
||||||
|
.wrangler/
|
||||||
|
|
||||||
# Sentry Config File
|
# Sentry Config File
|
||||||
.env.sentry-build-plugin
|
.env.sentry-build-plugin
|
||||||
|
|||||||
+3
-40
@@ -1,4 +1,3 @@
|
|||||||
import { withSentryConfig } from "@sentry/nextjs";
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
import createMDX from "@next/mdx";
|
import createMDX from "@next/mdx";
|
||||||
@@ -10,6 +9,7 @@ const withMDX = createMDX({
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
|
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
|
||||||
|
serverExternalPackages: ["@prisma/client", ".prisma/client", "sharp", "pg", "@prisma/adapter-pg"],
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
// Configure webpack for raw imports
|
// Configure webpack for raw imports
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
@@ -19,8 +19,6 @@ const nextConfig: NextConfig = {
|
|||||||
});
|
});
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
// Enable standalone output for Docker
|
|
||||||
output: "standalone",
|
|
||||||
// Experimental features
|
// Experimental features
|
||||||
experimental: {
|
experimental: {
|
||||||
// Enable server actions
|
// Enable server actions
|
||||||
@@ -30,6 +28,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
// Image optimization
|
// Image optimization
|
||||||
images: {
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
@@ -65,40 +64,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withSentryConfig(withMDX(withNextIntl(nextConfig)), {
|
export default withMDX(withNextIntl(nextConfig));
|
||||||
// For all available options, see:
|
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
|
||||||
|
|
||||||
org: "promptschat",
|
|
||||||
|
|
||||||
project: "prompts-chat",
|
|
||||||
|
|
||||||
// Only print logs for uploading source maps in CI
|
|
||||||
silent: !process.env.CI,
|
|
||||||
|
|
||||||
// For all available options, see:
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
|
||||||
|
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
|
||||||
widenClientFileUpload: true,
|
|
||||||
|
|
||||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
|
||||||
// This can increase your server load as well as your hosting bill.
|
|
||||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
|
||||||
// side errors will fail.
|
|
||||||
tunnelRoute: "/monitoring",
|
|
||||||
|
|
||||||
webpack: {
|
|
||||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
|
||||||
// See the following for more information:
|
|
||||||
// https://docs.sentry.io/product/crons/
|
|
||||||
// https://vercel.com/docs/cron-jobs
|
|
||||||
automaticVercelMonitors: true,
|
|
||||||
|
|
||||||
// Tree-shaking options for reducing bundle size
|
|
||||||
treeshake: {
|
|
||||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
|
||||||
removeDebugLogging: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||||
|
|
||||||
|
export default defineCloudflareConfig();
|
||||||
Generated
+3803
-1566
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -8,6 +8,9 @@
|
|||||||
"db:deploy": "prisma migrate deploy",
|
"db:deploy": "prisma migrate deploy",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||||
|
"deploy": "bash scripts/cf-deploy.sh",
|
||||||
|
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
@@ -37,7 +40,11 @@
|
|||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@neondatabase/serverless": "^1.0.2",
|
||||||
"@next/mdx": "^16.1.1",
|
"@next/mdx": "^16.1.1",
|
||||||
|
"@opennextjs/cloudflare": "^1.17.1",
|
||||||
|
"@prisma/adapter-neon": "^7.5.0",
|
||||||
|
"@prisma/adapter-pg": "^7.5.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
@@ -66,11 +73,13 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"next": "^16.0.10",
|
"next": "^16.0.10",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-intl": "^4.5.8",
|
"next-intl": "^4.5.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openai": "^6.10.0",
|
"openai": "^6.10.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"pino": "^10.1.0",
|
"pino": "^10.1.0",
|
||||||
"prompts.chat": "^0.0.7",
|
"prompts.chat": "^0.0.7",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
@@ -95,6 +104,7 @@
|
|||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.18.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -111,7 +121,8 @@
|
|||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8",
|
||||||
|
"wrangler": "^4.75.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24.x"
|
"node": "24.x"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["driverAdapters"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Cloudflare Workers Deploy Script"
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
SERVER_DIR=".open-next/server-functions/default"
|
||||||
|
|
||||||
|
# --- Step 1: Build with OpenNext ---
|
||||||
|
echo ""
|
||||||
|
echo "📦 Step 1: Building with OpenNext..."
|
||||||
|
CLOUDFLARE=1 npx opennextjs-cloudflare build
|
||||||
|
|
||||||
|
# --- Step 2: Post-build cleanup ---
|
||||||
|
echo ""
|
||||||
|
echo "📦 Step 2: Post-build patches..."
|
||||||
|
|
||||||
|
# --- Step 2b: Patch handler.mjs for Next.js 16 compat ---
|
||||||
|
# OpenNext's loadManifest throws on manifests it doesn't recognize.
|
||||||
|
# Next.js 16 adds new manifests (prefetch-hints, subresource-integrity, fallback-build).
|
||||||
|
# Patch: return {} for unknown manifests instead of throwing.
|
||||||
|
HANDLER="$SERVER_DIR/handler.mjs"
|
||||||
|
if grep -q 'Unexpected loadManifest' "$HANDLER"; then
|
||||||
|
sed -i '' 's|throw new Error(`Unexpected loadManifest(${path2}) call!`)|return{}|' "$HANDLER"
|
||||||
|
echo " ✅ Patched loadManifest to handle unknown Next.js 16 manifests"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# --- Step 3: Stub heavy WASM/binary files to reduce bundle size ---
|
||||||
|
echo ""
|
||||||
|
echo "📦 Step 3: Stubbing heavy WASM files..."
|
||||||
|
|
||||||
|
# handler.mjs references files with absolute paths, causing wrangler's
|
||||||
|
# module collector to resolve them as: <server_dir>/<absolute_path>.
|
||||||
|
# We create stubs at both the normal and doubled path locations.
|
||||||
|
stub_wasm_file() {
|
||||||
|
local REL_PATH="$1"
|
||||||
|
local NORMAL="$SERVER_DIR/$REL_PATH"
|
||||||
|
local DOUBLED="$SERVER_DIR$PROJECT_ROOT/$SERVER_DIR/$REL_PATH"
|
||||||
|
|
||||||
|
for TARGET in "$NORMAL" "$DOUBLED"; do
|
||||||
|
mkdir -p "$(dirname "$TARGET")"
|
||||||
|
printf '\x00asm\x01\x00\x00\x00' > "$TARGET"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
stub_empty_file() {
|
||||||
|
local REL_PATH="$1"
|
||||||
|
local NORMAL="$SERVER_DIR/$REL_PATH"
|
||||||
|
local DOUBLED="$SERVER_DIR$PROJECT_ROOT/$SERVER_DIR/$REL_PATH"
|
||||||
|
|
||||||
|
for TARGET in "$NORMAL" "$DOUBLED"; do
|
||||||
|
mkdir -p "$(dirname "$TARGET")"
|
||||||
|
: > "$TARGET"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stub @vercel/og WASM + fonts (saves ~1.5 MiB gzipped)
|
||||||
|
# Stub in .open-next bundle
|
||||||
|
stub_wasm_file "node_modules/next/dist/compiled/@vercel/og/resvg.wasm"
|
||||||
|
stub_empty_file "node_modules/next/dist/compiled/@vercel/og/Geist-Regular.ttf.bin"
|
||||||
|
stub_empty_file "node_modules/next/dist/compiled/@vercel/og/Geist-SemiBold.ttf.bin"
|
||||||
|
# Also stub in project root node_modules (wrangler resolves from here too)
|
||||||
|
REAL_RESVG="$PROJECT_ROOT/node_modules/next/dist/compiled/@vercel/og/resvg.wasm"
|
||||||
|
if [ -f "$REAL_RESVG" ] && [ "$(wc -c < "$REAL_RESVG")" -gt 100 ]; then
|
||||||
|
cp "$REAL_RESVG" "$REAL_RESVG.bak"
|
||||||
|
printf '\x00asm\x01\x00\x00\x00' > "$REAL_RESVG"
|
||||||
|
fi
|
||||||
|
echo " ✅ Stubbed @vercel/og WASM + fonts"
|
||||||
|
|
||||||
|
# Keep Prisma WASM query engine (needed for query compilation with driver adapters)
|
||||||
|
# Aggressively clean @prisma to fit within 10 MiB:
|
||||||
|
PRISMA_RUNTIME="$SERVER_DIR/node_modules/@prisma/client/runtime"
|
||||||
|
PRISMA_CLIENT="$SERVER_DIR/node_modules/@prisma/client"
|
||||||
|
if [ -d "$PRISMA_RUNTIME" ]; then
|
||||||
|
# Remove non-PostgreSQL engines
|
||||||
|
find "$PRISMA_RUNTIME" -name "*cockroachdb*" -delete 2>/dev/null
|
||||||
|
find "$PRISMA_RUNTIME" -name "*mysql*" -delete 2>/dev/null
|
||||||
|
find "$PRISMA_RUNTIME" -name "*sqlite*" -delete 2>/dev/null
|
||||||
|
find "$PRISMA_RUNTIME" -name "*sqlserver*" -delete 2>/dev/null
|
||||||
|
# Remove source maps (not needed at runtime)
|
||||||
|
find "$PRISMA_RUNTIME" -name "*.js.map" -delete 2>/dev/null
|
||||||
|
find "$PRISMA_RUNTIME" -name "*.mjs.map" -delete 2>/dev/null
|
||||||
|
# Remove duplicate .mjs versions of base64 WASM (keep only .js)
|
||||||
|
rm -f "$PRISMA_RUNTIME"/query_engine_bg.postgresql.wasm-base64.mjs 2>/dev/null
|
||||||
|
rm -f "$PRISMA_RUNTIME"/query_compiler_bg.postgresql.wasm-base64.mjs 2>/dev/null
|
||||||
|
# Remove query compiler WASM (only query engine is needed for driver adapters)
|
||||||
|
rm -f "$PRISMA_RUNTIME"/query_compiler_bg.postgresql.wasm-base64.js 2>/dev/null
|
||||||
|
# Remove unused runtimes (we only use wasm-engine-edge)
|
||||||
|
rm -f "$PRISMA_RUNTIME"/binary.js "$PRISMA_RUNTIME"/binary.mjs 2>/dev/null
|
||||||
|
rm -f "$PRISMA_RUNTIME"/library.js "$PRISMA_RUNTIME"/library.mjs 2>/dev/null
|
||||||
|
rm -f "$PRISMA_RUNTIME"/react-native.js 2>/dev/null
|
||||||
|
rm -f "$PRISMA_RUNTIME"/wasm-compiler-edge.js "$PRISMA_RUNTIME"/wasm-compiler-edge.mjs 2>/dev/null
|
||||||
|
rm -f "$PRISMA_RUNTIME"/edge-esm.js 2>/dev/null
|
||||||
|
rm -f "$PRISMA_RUNTIME"/index-browser.js "$PRISMA_RUNTIME"/index-browser.mjs 2>/dev/null
|
||||||
|
rm -f "$PRISMA_RUNTIME"/client.js "$PRISMA_RUNTIME"/client.mjs 2>/dev/null
|
||||||
|
# Remove generator-build (not needed at runtime)
|
||||||
|
rm -rf "$PRISMA_CLIENT/generator-build" 2>/dev/null
|
||||||
|
echo " ✅ Cleaned Prisma: kept only PostgreSQL WASM engine + wasm-engine-edge runtime"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Step 4: Check bundle size ---
|
||||||
|
echo ""
|
||||||
|
echo "📦 Step 4: Checking bundle size..."
|
||||||
|
npx wrangler deploy --dry-run --outdir /tmp/cf-dry-run 2>&1 | grep "Total Upload" || true
|
||||||
|
|
||||||
|
# --- Step 5: Deploy ---
|
||||||
|
echo ""
|
||||||
|
echo "📦 Step 5: Deploying to Cloudflare Workers..."
|
||||||
|
npx opennextjs-cloudflare deploy
|
||||||
|
|
||||||
|
# --- Step 6: Restore stubbed files in project root ---
|
||||||
|
REAL_RESVG="$PROJECT_ROOT/node_modules/next/dist/compiled/@vercel/og/resvg.wasm"
|
||||||
|
if [ -f "$REAL_RESVG.bak" ]; then
|
||||||
|
mv "$REAL_RESVG.bak" "$REAL_RESVG"
|
||||||
|
echo " ✅ Restored project root resvg.wasm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Deploy complete!"
|
||||||
@@ -1,304 +1,5 @@
|
|||||||
import { ImageResponse } from "next/og";
|
// OG image generation disabled for Cloudflare Workers (resvg.wasm too large)
|
||||||
import { db } from "@/lib/db";
|
// Falls back to static og.png from the public directory
|
||||||
import { getConfig } from "@/lib/config";
|
export default function OGImage() {
|
||||||
|
return new Response(null, { status: 302, headers: { Location: "/og.png" } });
|
||||||
export const alt = "User Profile";
|
|
||||||
export const size = {
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
};
|
|
||||||
export const contentType = "image/png";
|
|
||||||
|
|
||||||
const radiusMap: Record<string, number> = {
|
|
||||||
none: 0,
|
|
||||||
sm: 8,
|
|
||||||
md: 12,
|
|
||||||
lg: 16,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function OGImage({ params }: { params: Promise<{ username: string }> }) {
|
|
||||||
const { username: rawUsername } = await params;
|
|
||||||
const config = await getConfig();
|
|
||||||
const radius = radiusMap[config.theme?.radius || "sm"] || 8;
|
|
||||||
|
|
||||||
// Only support /@username format
|
|
||||||
const decodedUsername = decodeURIComponent(rawUsername);
|
|
||||||
if (!decodedUsername.startsWith("@")) {
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "#0a0a0a",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
User Not Found
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ ...size }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const username = decodedUsername.slice(1);
|
|
||||||
|
|
||||||
const user = await db.user.findFirst({
|
|
||||||
where: { username: { equals: username, mode: "insensitive" } },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
username: true,
|
|
||||||
avatar: true,
|
|
||||||
role: true,
|
|
||||||
createdAt: true,
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
prompts: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "#0a0a0a",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
User Not Found
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ ...size }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total upvotes received
|
|
||||||
const totalUpvotes = await db.promptVote.count({
|
|
||||||
where: {
|
|
||||||
prompt: {
|
|
||||||
authorId: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Format join date
|
|
||||||
const joinDate = new Intl.DateTimeFormat("en-US", {
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(user.createdAt);
|
|
||||||
|
|
||||||
const primaryColor = config.theme?.colors?.primary || "#6366f1";
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
padding: "48px 56px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Top Bar - Branding */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 24, fontWeight: 600, color: primaryColor }}>
|
|
||||||
{config.branding.name}
|
|
||||||
</span>
|
|
||||||
{user.role === "ADMIN" && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: primaryColor + "20",
|
|
||||||
color: primaryColor,
|
|
||||||
padding: "8px 16px",
|
|
||||||
borderRadius: radius * 2,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flex: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 48,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Avatar */}
|
|
||||||
{user.avatar ? (
|
|
||||||
<img
|
|
||||||
src={user.avatar}
|
|
||||||
width={180}
|
|
||||||
height={180}
|
|
||||||
style={{
|
|
||||||
borderRadius: 90,
|
|
||||||
border: `4px solid ${primaryColor}30`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 180,
|
|
||||||
height: 180,
|
|
||||||
borderRadius: 90,
|
|
||||||
backgroundColor: "#f4f4f5",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "#71717a",
|
|
||||||
fontSize: 72,
|
|
||||||
fontWeight: 600,
|
|
||||||
border: `4px solid ${primaryColor}30`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(user.name || user.username).charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Info */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
fontSize: 56,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#18181b",
|
|
||||||
lineHeight: 1.2,
|
|
||||||
letterSpacing: "-0.02em",
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.name || user.username}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Username */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
fontSize: 28,
|
|
||||||
color: "#71717a",
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
@{user.username}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Prompts */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
backgroundColor: "#f4f4f5",
|
|
||||||
padding: "14px 24px",
|
|
||||||
borderRadius: radius * 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#71717a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
|
||||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
||||||
</svg>
|
|
||||||
<span style={{ fontSize: 26, fontWeight: 600, color: "#18181b" }}>
|
|
||||||
{user._count.prompts}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 22, color: "#71717a" }}>
|
|
||||||
prompts
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upvotes */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
backgroundColor: primaryColor + "15",
|
|
||||||
padding: "14px 24px",
|
|
||||||
borderRadius: radius * 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke={primaryColor} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="m18 15-6-6-6 6" />
|
|
||||||
</svg>
|
|
||||||
<span style={{ fontSize: 26, fontWeight: 600, color: primaryColor }}>
|
|
||||||
{totalUpvotes}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 22, color: "#71717a" }}>
|
|
||||||
upvotes
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Joined */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
backgroundColor: "#f4f4f5",
|
|
||||||
padding: "14px 24px",
|
|
||||||
borderRadius: radius * 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#71717a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
|
|
||||||
<line x1="16" x2="16" y1="2" y2="6" />
|
|
||||||
<line x1="8" x2="8" y1="2" y2="6" />
|
|
||||||
<line x1="3" x2="21" y1="10" y2="10" />
|
|
||||||
</svg>
|
|
||||||
<span style={{ fontSize: 22, color: "#71717a" }}>
|
|
||||||
Joined {joinDate}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ ...size }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { getStoragePlugin } from "@/lib/plugins/registry";
|
import { getStoragePlugin } from "@/lib/plugins/registry";
|
||||||
import sharp from "sharp";
|
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4MB for images
|
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4MB for images
|
||||||
const MAX_VIDEO_SIZE = 4 * 1024 * 1024; // 4MB for videos (Vercel serverless limit)
|
const MAX_VIDEO_SIZE = 4 * 1024 * 1024; // 4MB for videos (Vercel serverless limit)
|
||||||
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
const ALLOWED_VIDEO_TYPES = ["video/mp4"];
|
const ALLOWED_VIDEO_TYPES = ["video/mp4"];
|
||||||
|
|
||||||
async function compressToJpg(buffer: Buffer): Promise<Buffer> {
|
async function compressToJpg(buffer: Buffer): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
// Use turbopackIgnore to prevent Turbopack from tracing this native module
|
||||||
|
// sharp is unavailable on Cloudflare Workers and will gracefully fallback
|
||||||
|
const sharp = (await import(/* turbopackIgnore: true */ "sharp")).default;
|
||||||
return await sharp(buffer)
|
return await sharp(buffer)
|
||||||
.jpeg({ quality: 90, mozjpeg: true })
|
.jpeg({ quality: 90, mozjpeg: true })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
} catch {
|
||||||
|
// sharp is not available (e.g. on Cloudflare Workers), return buffer as-is
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import NextError from "next/error";
|
import NextError from "next/error";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export default function GlobalError({
|
export default function GlobalError({
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
console.error(error);
|
||||||
Sentry.captureException(error);
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
{/* `NextError` is the default Next.js error page component. Its type
|
|
||||||
definition requires a `statusCode` prop. However, since the App Router
|
|
||||||
does not expose status codes for errors, we simply pass 0 to render a
|
|
||||||
generic error message. */}
|
|
||||||
<NextError statusCode={0} />
|
<NextError statusCode={0} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,360 +1,5 @@
|
|||||||
import { ImageResponse } from "next/og";
|
// OG image generation disabled for Cloudflare Workers (resvg.wasm too large)
|
||||||
import { db } from "@/lib/db";
|
// Falls back to static og.png from the public directory
|
||||||
import { getConfig } from "@/lib/config";
|
export default function OGImage() {
|
||||||
|
return new Response(null, { status: 302, headers: { Location: "/og.png" } });
|
||||||
export const alt = "Prompt Preview";
|
|
||||||
export const size = {
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
};
|
|
||||||
export const contentType = "image/png";
|
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
|
||||||
TEXT: "Text Prompt",
|
|
||||||
IMAGE: "Image Prompt",
|
|
||||||
VIDEO: "Video Prompt",
|
|
||||||
AUDIO: "Audio Prompt",
|
|
||||||
STRUCTURED: "Structured",
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
|
||||||
TEXT: "#3b82f6",
|
|
||||||
IMAGE: "#8b5cf6",
|
|
||||||
VIDEO: "#ec4899",
|
|
||||||
AUDIO: "#f59e0b",
|
|
||||||
STRUCTURED: "#10b981",
|
|
||||||
};
|
|
||||||
|
|
||||||
const radiusMap: Record<string, number> = {
|
|
||||||
none: 0,
|
|
||||||
sm: 8,
|
|
||||||
md: 12,
|
|
||||||
lg: 16,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the prompt ID from a URL parameter that may contain a slug
|
|
||||||
*/
|
|
||||||
function extractPromptId(idParam: string): string {
|
|
||||||
const underscoreIndex = idParam.indexOf("_");
|
|
||||||
if (underscoreIndex !== -1) {
|
|
||||||
return idParam.substring(0, underscoreIndex);
|
|
||||||
}
|
|
||||||
return idParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function OGImage({ params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id: idParam } = await params;
|
|
||||||
const id = extractPromptId(idParam);
|
|
||||||
const config = await getConfig();
|
|
||||||
const radius = radiusMap[config.theme?.radius || "sm"] || 8;
|
|
||||||
const radiusLg = radius * 2; // For larger elements like content box
|
|
||||||
|
|
||||||
const prompt = await db.prompt.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
username: true,
|
|
||||||
avatar: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
icon: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: { votes: true, contributors: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!prompt) {
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "#0a0a0a",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Prompt Not Found
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ ...size }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For structured prompts, try to prettify JSON/YAML
|
|
||||||
let displayContent = prompt.content;
|
|
||||||
if (prompt.type === "STRUCTURED") {
|
|
||||||
try {
|
|
||||||
if (prompt.structuredFormat === "JSON") {
|
|
||||||
const parsed = JSON.parse(prompt.content);
|
|
||||||
displayContent = JSON.stringify(parsed, null, 2);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep original if parsing fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const truncatedContent = displayContent.length > 400
|
|
||||||
? displayContent.slice(0, 400) + "..."
|
|
||||||
: displayContent;
|
|
||||||
|
|
||||||
const isImagePrompt = prompt.type === "IMAGE" && prompt.mediaUrl;
|
|
||||||
const isStructuredPrompt = prompt.type === "STRUCTURED";
|
|
||||||
const voteCount = prompt._count.votes;
|
|
||||||
const contributorCount = prompt._count.contributors;
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
padding: "48px 56px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Top Bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Left: Branding */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
|
||||||
<span style={{ fontSize: 24, fontWeight: 600, color: config.theme?.colors?.primary || "#6366f1" }}>
|
|
||||||
{config.branding.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Stats */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
|
||||||
{/* Upvotes */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke={config.theme?.colors?.primary || "#6366f1"} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="m18 15-6-6-6 6" />
|
|
||||||
</svg>
|
|
||||||
<span style={{ fontSize: 24, fontWeight: 600, color: config.theme?.colors?.primary || "#6366f1" }}>
|
|
||||||
{voteCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contributors */}
|
|
||||||
{contributorCount > 0 && (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
<span style={{ fontSize: 20, color: "#71717a" }}>
|
|
||||||
+{contributorCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flex: 1,
|
|
||||||
gap: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Left Content */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Title Row with Category and Type Badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 20,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#18181b",
|
|
||||||
lineHeight: 1.2,
|
|
||||||
letterSpacing: "-0.02em",
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{prompt.title}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
|
|
||||||
{/* Category Badge */}
|
|
||||||
{prompt.category && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
backgroundColor: "#f4f4f5",
|
|
||||||
color: "#71717a",
|
|
||||||
padding: "8px 14px",
|
|
||||||
borderRadius: radius * 2.5,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{prompt.category.icon && <span>{prompt.category.icon}</span>}
|
|
||||||
<span>{prompt.category.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Type Badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
backgroundColor: typeColors[prompt.type] + "30",
|
|
||||||
color: typeColors[prompt.type],
|
|
||||||
padding: "8px 16px",
|
|
||||||
borderRadius: radius * 2.5,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{typeLabels[prompt.type] || prompt.type}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Preview */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
fontSize: isStructuredPrompt ? 18 : 22,
|
|
||||||
color: "#3f3f46",
|
|
||||||
lineHeight: isStructuredPrompt ? 1.4 : 1.6,
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
padding: "12px 14px",
|
|
||||||
borderRadius: radius,
|
|
||||||
border: `2px solid ${config.theme?.colors?.primary || "#6366f1"}20`,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isStructuredPrompt && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 12,
|
|
||||||
paddingBottom: 12,
|
|
||||||
borderBottom: "1px solid #e4e4e7",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: config.theme?.colors?.primary || "#6366f1", fontWeight: 600, fontSize: 14 }}>
|
|
||||||
{prompt.structuredFormat || "JSON"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: "flex", whiteSpace: isStructuredPrompt ? "pre" : "pre-wrap" }}>
|
|
||||||
{truncatedContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer - Author Info */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
marginTop: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
|
||||||
{/* Avatar */}
|
|
||||||
{prompt.author.avatar ? (
|
|
||||||
<img
|
|
||||||
src={prompt.author.avatar}
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
style={{ borderRadius: 24, border: "2px solid #e4e4e7" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 24,
|
|
||||||
backgroundColor: "#f4f4f5",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "#71717a",
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
border: "2px solid #e4e4e7",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(prompt.author.name || prompt.author.username).charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
||||||
<span style={{ color: "#18181b", fontSize: 20, fontWeight: 600 }}>
|
|
||||||
{prompt.author.name || prompt.author.username}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "#71717a", fontSize: 16 }}>
|
|
||||||
@{prompt.author.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image Preview (for image prompts) */}
|
|
||||||
{isImagePrompt && (
|
|
||||||
<img
|
|
||||||
src={prompt.mediaUrl!}
|
|
||||||
width={280}
|
|
||||||
height={420}
|
|
||||||
style={{
|
|
||||||
borderRadius: radiusLg,
|
|
||||||
objectFit: "cover",
|
|
||||||
objectPosition: "center",
|
|
||||||
border: "2px solid #e4e4e7",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ ...size }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { default, alt, size, contentType } from "./opengraph-image";
|
export { default } from "./opengraph-image";
|
||||||
|
|||||||
@@ -1,81 +1 @@
|
|||||||
// This file configures the initialization of Sentry on the client.
|
// Sentry client instrumentation disabled for Cloudflare Workers build
|
||||||
// The added config here will be used whenever a users loads a page in their browser.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
// Patterns to ignore - typically from browser extensions or third-party scripts
|
|
||||||
const ignoreErrors = [
|
|
||||||
// Browser extension errors
|
|
||||||
/MetaMask/i,
|
|
||||||
/ethereum/i,
|
|
||||||
/tronlink/i,
|
|
||||||
/tron/i,
|
|
||||||
/webkit\.messageHandlers/i,
|
|
||||||
/disconnected port/i,
|
|
||||||
/__firefox__/i,
|
|
||||||
// DOM manipulation errors often caused by extensions
|
|
||||||
/removeChild.*not a child/i,
|
|
||||||
/parentNode.*null/i,
|
|
||||||
// Third-party/extension scripts
|
|
||||||
/CONFIG.*not defined/i,
|
|
||||||
/Can't find variable: CONFIG/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: "https://9c2eb3b4441745efad28a908001c30bf@o4510673866063872.ingest.de.sentry.io/4510673871306832",
|
|
||||||
|
|
||||||
// Disable Sentry in development
|
|
||||||
enabled: process.env.NODE_ENV === "production",
|
|
||||||
|
|
||||||
// Add optional integrations for additional features
|
|
||||||
integrations: [Sentry.replayIntegration()],
|
|
||||||
|
|
||||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
|
||||||
tracesSampleRate: 1,
|
|
||||||
// Enable logs to be sent to Sentry
|
|
||||||
enableLogs: true,
|
|
||||||
|
|
||||||
// Define how likely Replay events are sampled.
|
|
||||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
|
||||||
// in development and sample at a lower rate in production
|
|
||||||
replaysSessionSampleRate: 0.1,
|
|
||||||
|
|
||||||
// Define how likely Replay events are sampled when an error occurs.
|
|
||||||
replaysOnErrorSampleRate: 1.0,
|
|
||||||
|
|
||||||
// Enable sending user PII (Personally Identifiable Information)
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
|
||||||
sendDefaultPii: true,
|
|
||||||
|
|
||||||
// Filter out browser extension and third-party script errors
|
|
||||||
beforeSend(event) {
|
|
||||||
const message = event.exception?.values?.[0]?.value || "";
|
|
||||||
const type = event.exception?.values?.[0]?.type || "";
|
|
||||||
const fullMessage = `${type}: ${message}`;
|
|
||||||
|
|
||||||
// Check if error matches any ignore pattern
|
|
||||||
if (ignoreErrors.some((pattern) => pattern.test(fullMessage))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out errors from browser extension scripts
|
|
||||||
const frames = event.exception?.values?.[0]?.stacktrace?.frames || [];
|
|
||||||
const hasExtensionFrame = frames.some((frame) => {
|
|
||||||
const filename = frame.filename || "";
|
|
||||||
return (
|
|
||||||
filename.includes("extension://") ||
|
|
||||||
filename.includes("moz-extension://") ||
|
|
||||||
filename.includes("chrome-extension://")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasExtensionFrame) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return event;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
|
||||||
|
|||||||
+3
-13
@@ -1,13 +1,3 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
// Sentry disabled for Cloudflare Workers to reduce bundle size
|
||||||
|
export async function register() {}
|
||||||
export async function register() {
|
export const onRequestError = () => {};
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
||||||
await import("../sentry.server.config");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.NEXT_RUNTIME === "edge") {
|
|
||||||
await import("../sentry.edge.config");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const onRequestError = Sentry.captureRequestError;
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function loadPrompt(relativePath: string): PromptFile {
|
|||||||
return promptCache.get(relativePath)!;
|
return promptCache.get(relativePath)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const absolutePath = join(process.cwd(), relativePath);
|
const absolutePath = join(process.cwd(), relativePath);
|
||||||
const content = readFileSync(absolutePath, "utf-8");
|
const content = readFileSync(absolutePath, "utf-8");
|
||||||
const prompt = parse(content) as PromptFile;
|
const prompt = parse(content) as PromptFile;
|
||||||
@@ -41,6 +42,17 @@ export function loadPrompt(relativePath: string): PromptFile {
|
|||||||
promptCache.set(relativePath, prompt);
|
promptCache.set(relativePath, prompt);
|
||||||
|
|
||||||
return prompt;
|
return prompt;
|
||||||
|
} catch {
|
||||||
|
// On edge runtimes (e.g. Cloudflare Workers) filesystem is unavailable.
|
||||||
|
// Return a minimal fallback so the module can still load.
|
||||||
|
const fallback: PromptFile = {
|
||||||
|
name: relativePath,
|
||||||
|
description: "",
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
promptCache.set(relativePath, fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+25
-7
@@ -1,11 +1,23 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { PrismaNeon } from "@prisma/adapter-neon";
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
prisma: PrismaClient | undefined;
|
prisma: PrismaClient | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Configure Prisma for serverless environments with connection pooling
|
// Lazily initialize Prisma client on first property access.
|
||||||
const prismaClientSingleton = () => {
|
// On Cloudflare Workers: uses Neon serverless adapter (HTTP-based).
|
||||||
|
// On Node.js (Vercel, local dev): uses standard PrismaClient with DATABASE_URL.
|
||||||
|
function createPrismaClient(): PrismaClient {
|
||||||
|
const dbUrl = process.env.DATABASE_URL || "";
|
||||||
|
|
||||||
|
// Neon database: use Neon HTTP adapter (works on both edge and Node.js)
|
||||||
|
if (dbUrl.includes("neon.tech")) {
|
||||||
|
const adapter = new PrismaNeon({ connectionString: dbUrl });
|
||||||
|
return new PrismaClient({ adapter });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Node.js environment with non-Neon database
|
||||||
return new PrismaClient({
|
return new PrismaClient({
|
||||||
log:
|
log:
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
@@ -13,9 +25,15 @@ const prismaClientSingleton = () => {
|
|||||||
: ["error"],
|
: ["error"],
|
||||||
datasourceUrl: process.env.DATABASE_URL,
|
datasourceUrl: process.env.DATABASE_URL,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
export const db = globalForPrisma.prisma ?? prismaClientSingleton();
|
// Use a Proxy so existing code (`db.user.findMany()`) works unchanged.
|
||||||
|
// The real PrismaClient is created on first property access (during request handling).
|
||||||
// Always reuse the same instance to prevent connection pool exhaustion
|
export const db: PrismaClient = new Proxy({} as PrismaClient, {
|
||||||
globalForPrisma.prisma = db;
|
get(_target, prop, receiver) {
|
||||||
|
if (!globalForPrisma.prisma) {
|
||||||
|
globalForPrisma.prisma = createPrismaClient();
|
||||||
|
}
|
||||||
|
return Reflect.get(globalForPrisma.prisma, prop, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,34 +1,10 @@
|
|||||||
import { registerStoragePlugin } from "../registry";
|
import { registerStoragePlugin } from "../registry";
|
||||||
import { urlStoragePlugin } from "./url";
|
import { urlStoragePlugin } from "./url";
|
||||||
|
|
||||||
const ENABLED_STORAGE: string = process.env.ENABLED_STORAGE || "url";
|
|
||||||
|
|
||||||
// Register all built-in storage plugins
|
// Register all built-in storage plugins
|
||||||
|
// S3 and DO Spaces storage removed for Cloudflare Workers (saves ~850 KiB from @aws-sdk)
|
||||||
export function registerBuiltInStoragePlugins(): void {
|
export function registerBuiltInStoragePlugins(): void {
|
||||||
if (ENABLED_STORAGE === "url") {
|
|
||||||
registerStoragePlugin(urlStoragePlugin);
|
registerStoragePlugin(urlStoragePlugin);
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ENABLED_STORAGE === "s3") {
|
|
||||||
// To enable S3 storage:
|
|
||||||
// 1. Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY env vars
|
|
||||||
import("./s3").then(({ s3StoragePlugin }) => {
|
|
||||||
registerStoragePlugin(s3StoragePlugin);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ENABLED_STORAGE === "do-spaces") {
|
|
||||||
// To enable DO Spaces storage:
|
|
||||||
// 1. Set DO_SPACES_BUCKET, DO_SPACES_REGION, DO_SPACES_ACCESS_KEY_ID, DO_SPACES_SECRET_ACCESS_KEY env vars
|
|
||||||
import("./do-spaces").then(({ doSpacesStoragePlugin }) => {
|
|
||||||
registerStoragePlugin(doSpacesStoragePlugin);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`No storage plugin enabled for "${ENABLED_STORAGE}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { urlStoragePlugin };
|
export { urlStoragePlugin };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// Rewrite .prompt.md and .prompt.yml requests to the raw API route
|
// Rewrite .prompt.md and .prompt.yml requests to the raw API route
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import type { NextPageContext } from "next";
|
import type { NextPageContext } from "next";
|
||||||
import Error, { ErrorProps } from "next/error";
|
import Error, { ErrorProps } from "next/error";
|
||||||
|
|
||||||
@@ -7,11 +6,6 @@ const CustomErrorComponent = (props: ErrorProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => {
|
CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => {
|
||||||
// In case this is running in a serverless function, await this in order to give Sentry
|
|
||||||
// time to send the error before the lambda exits
|
|
||||||
await Sentry.captureUnderscoreErrorException(contextData);
|
|
||||||
|
|
||||||
// This will contain the status code of the response
|
|
||||||
return Error.getInitialProps(contextData);
|
return Error.getInitialProps(contextData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "prompts-chat",
|
||||||
|
// Set compatibility date
|
||||||
|
"compatibility_date": "2026-03-18",
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat"
|
||||||
|
],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"hyperdrive": [
|
||||||
|
{
|
||||||
|
"binding": "HYPERDRIVE",
|
||||||
|
"id": "2c6ef99cbde84baaac5edacc418eee72",
|
||||||
|
"localConnectionString": "postgresql://fka:postgres@localhost:5433/prompts_chat?schema=public"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user