Spaces:
Build error
Build error
Gmagl commited on
Commit ·
333c51a
1
Parent(s): ec1df90
Add Sofia Cloud complete files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +8 -35
- Dockerfile +56 -0
- README.md +23 -5
- bun.lock +0 -0
- components.json +21 -0
- entrypoint.sh +10 -0
- eslint.config.mjs +50 -0
- next.config.ts +12 -0
- package.json +94 -0
- postcss.config.mjs +5 -0
- prisma/schema.prisma +436 -0
- public/logo.svg +29 -0
- public/robots.txt +14 -0
- src/app/api/agent/route.ts +122 -0
- src/app/api/analyze/route.ts +234 -0
- src/app/api/automation/route.ts +396 -0
- src/app/api/censor/route.ts +191 -0
- src/app/api/characters/route.ts +212 -0
- src/app/api/content/route.ts +89 -0
- src/app/api/generate/image/route.ts +237 -0
- src/app/api/generate/video/route.ts +221 -0
- src/app/api/influencers/route.ts +450 -0
- src/app/api/monetization/route.ts +372 -0
- src/app/api/pets/route.ts +327 -0
- src/app/api/posts/route.ts +330 -0
- src/app/api/projects/route.ts +74 -0
- src/app/api/prompt-engineer/route.ts +216 -0
- src/app/api/repos/[id]/route.ts +114 -0
- src/app/api/repos/route.ts +126 -0
- src/app/api/route.ts +5 -0
- src/app/api/storytelling/route.ts +347 -0
- src/app/api/trends/route.ts +343 -0
- src/app/globals.css +122 -0
- src/app/layout.tsx +51 -0
- src/app/page.tsx +1131 -0
- src/components/ui/accordion.tsx +66 -0
- src/components/ui/alert-dialog.tsx +157 -0
- src/components/ui/alert.tsx +66 -0
- src/components/ui/aspect-ratio.tsx +11 -0
- src/components/ui/avatar.tsx +53 -0
- src/components/ui/badge.tsx +46 -0
- src/components/ui/breadcrumb.tsx +109 -0
- src/components/ui/button.tsx +59 -0
- src/components/ui/calendar.tsx +213 -0
- src/components/ui/card.tsx +92 -0
- src/components/ui/carousel.tsx +241 -0
- src/components/ui/chart.tsx +353 -0
- src/components/ui/checkbox.tsx +32 -0
- src/components/ui/collapsible.tsx +33 -0
- src/components/ui/command.tsx +184 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,8 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.
|
| 3 |
-
*.
|
| 4 |
-
*.
|
| 5 |
-
*.
|
| 6 |
-
*.
|
| 7 |
-
*.
|
| 8 |
-
*.
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.ts text eol=lf
|
| 2 |
+
*.tsx text eol=lf
|
| 3 |
+
*.js text eol=lf
|
| 4 |
+
*.jsx text eol=lf
|
| 5 |
+
*.json text eol=lf
|
| 6 |
+
*.css text eol=lf
|
| 7 |
+
*.md text eol=lf
|
| 8 |
+
*.sh text eol=lf
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS builder
|
| 2 |
+
|
| 3 |
+
# Install bun
|
| 4 |
+
RUN npm install -g bun
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Copy package files first
|
| 9 |
+
COPY package.json bun.lock ./
|
| 10 |
+
|
| 11 |
+
# Install dependencies
|
| 12 |
+
RUN bun install --frozen-lockfile
|
| 13 |
+
|
| 14 |
+
# Copy source files
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Generate Prisma client
|
| 18 |
+
RUN bunx prisma generate
|
| 19 |
+
|
| 20 |
+
# Build the application
|
| 21 |
+
RUN bun run build
|
| 22 |
+
|
| 23 |
+
# Production image
|
| 24 |
+
FROM node:20-alpine
|
| 25 |
+
|
| 26 |
+
WORKDIR /app
|
| 27 |
+
|
| 28 |
+
# Install bun
|
| 29 |
+
RUN npm install -g bun
|
| 30 |
+
|
| 31 |
+
# Copy built application from builder
|
| 32 |
+
COPY --from=builder /app/.next ./.next
|
| 33 |
+
COPY --from=builder /app/public ./public
|
| 34 |
+
COPY --from=builder /app/package.json ./
|
| 35 |
+
COPY --from=builder /app/bun.lock ./
|
| 36 |
+
COPY --from=builder /app/prisma ./prisma
|
| 37 |
+
COPY --from=builder /app/node_modules ./node_modules
|
| 38 |
+
COPY --from=builder /app/next.config.ts ./
|
| 39 |
+
|
| 40 |
+
# Create data directory for SQLite
|
| 41 |
+
RUN mkdir -p /app/data
|
| 42 |
+
|
| 43 |
+
# Set environment variables
|
| 44 |
+
ENV NODE_ENV=production
|
| 45 |
+
ENV DATABASE_URL="file:/app/data/sofia.db"
|
| 46 |
+
ENV PORT=3000
|
| 47 |
+
ENV HOST=0.0.0.0
|
| 48 |
+
|
| 49 |
+
# Copy and setup entrypoint
|
| 50 |
+
COPY entrypoint.sh /entrypoint.sh
|
| 51 |
+
RUN chmod +x /entrypoint.sh
|
| 52 |
+
|
| 53 |
+
EXPOSE 3000
|
| 54 |
+
|
| 55 |
+
ENTRYPOINT ["/entrypoint.sh"]
|
| 56 |
+
CMD ["bun", "start"]
|
README.md
CHANGED
|
@@ -1,11 +1,29 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Sofía Cloud - Multiagente AI
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: violet
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# 🤖 Sofía Cloud - Sistema Multiagente AI para Monetización
|
| 12 |
+
|
| 13 |
+
Plataforma completa de monetización de contenido con IA.
|
| 14 |
+
|
| 15 |
+
## ✨ Características
|
| 16 |
+
|
| 17 |
+
- **Generación de Contenido**: Imágenes, videos, prompts optimizados
|
| 18 |
+
- **Monetización**: OnlyFans, Patreon, Fansly, Instagram, TikTok
|
| 19 |
+
- **Storytelling**: Historias con IA y cliffhangers
|
| 20 |
+
- **Influencers IA**: Análisis de patrones de éxito
|
| 21 |
+
- **Mascotas**: Sistema de pets con +35% engagement
|
| 22 |
+
- **Tendencias Virales**: Estrategias y predicción de viralidad
|
| 23 |
+
|
| 24 |
+
## 🚀 Tecnologías
|
| 25 |
+
|
| 26 |
+
- Next.js 15 + React + TypeScript
|
| 27 |
+
- Prisma + SQLite
|
| 28 |
+
- z-ai-web-dev-sdk (IA)
|
| 29 |
+
- Tailwind CSS + shadcn/ui
|
bun.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
components.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
},
|
| 20 |
+
"iconLibrary": "lucide"
|
| 21 |
+
}
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
|
| 3 |
+
# Initialize database if it doesn't exist
|
| 4 |
+
if [ ! -f /app/data/sofia.db ]; then
|
| 5 |
+
echo "Initializing database..."
|
| 6 |
+
cd /app && bunx prisma db push --skip-generate
|
| 7 |
+
fi
|
| 8 |
+
|
| 9 |
+
# Start the application
|
| 10 |
+
exec "$@"
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
| 2 |
+
import nextTypescript from "eslint-config-next/typescript";
|
| 3 |
+
import { dirname } from "path";
|
| 4 |
+
import { fileURLToPath } from "url";
|
| 5 |
+
|
| 6 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 7 |
+
const __dirname = dirname(__filename);
|
| 8 |
+
|
| 9 |
+
const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
|
| 10 |
+
rules: {
|
| 11 |
+
// TypeScript rules
|
| 12 |
+
"@typescript-eslint/no-explicit-any": "off",
|
| 13 |
+
"@typescript-eslint/no-unused-vars": "off",
|
| 14 |
+
"@typescript-eslint/no-non-null-assertion": "off",
|
| 15 |
+
"@typescript-eslint/ban-ts-comment": "off",
|
| 16 |
+
"@typescript-eslint/prefer-as-const": "off",
|
| 17 |
+
"@typescript-eslint/no-unused-disable-directive": "off",
|
| 18 |
+
|
| 19 |
+
// React rules
|
| 20 |
+
"react-hooks/exhaustive-deps": "off",
|
| 21 |
+
"react-hooks/purity": "off",
|
| 22 |
+
"react/no-unescaped-entities": "off",
|
| 23 |
+
"react/display-name": "off",
|
| 24 |
+
"react/prop-types": "off",
|
| 25 |
+
"react-compiler/react-compiler": "off",
|
| 26 |
+
|
| 27 |
+
// Next.js rules
|
| 28 |
+
"@next/next/no-img-element": "off",
|
| 29 |
+
"@next/next/no-html-link-for-pages": "off",
|
| 30 |
+
|
| 31 |
+
// General JavaScript rules
|
| 32 |
+
"prefer-const": "off",
|
| 33 |
+
"no-unused-vars": "off",
|
| 34 |
+
"no-console": "off",
|
| 35 |
+
"no-debugger": "off",
|
| 36 |
+
"no-empty": "off",
|
| 37 |
+
"no-irregular-whitespace": "off",
|
| 38 |
+
"no-case-declarations": "off",
|
| 39 |
+
"no-fallthrough": "off",
|
| 40 |
+
"no-mixed-spaces-and-tabs": "off",
|
| 41 |
+
"no-redeclare": "off",
|
| 42 |
+
"no-undef": "off",
|
| 43 |
+
"no-unreachable": "off",
|
| 44 |
+
"no-useless-escape": "off",
|
| 45 |
+
},
|
| 46 |
+
}, {
|
| 47 |
+
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
|
| 48 |
+
}];
|
| 49 |
+
|
| 50 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
output: "standalone",
|
| 5 |
+
/* config options here */
|
| 6 |
+
typescript: {
|
| 7 |
+
ignoreBuildErrors: true,
|
| 8 |
+
},
|
| 9 |
+
reactStrictMode: false,
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export default nextConfig;
|
package.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "nextjs_tailwind_shadcn_ts",
|
| 3 |
+
"version": "0.2.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev -p 3000 -H 0.0.0.0 2>&1 | tee dev.log",
|
| 7 |
+
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
| 8 |
+
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"db:push": "prisma db push",
|
| 11 |
+
"db:generate": "prisma generate",
|
| 12 |
+
"db:migrate": "prisma migrate dev",
|
| 13 |
+
"db:reset": "prisma migrate reset"
|
| 14 |
+
},
|
| 15 |
+
"dependencies": {
|
| 16 |
+
"@dnd-kit/core": "^6.3.1",
|
| 17 |
+
"@dnd-kit/sortable": "^10.0.0",
|
| 18 |
+
"@dnd-kit/utilities": "^3.2.2",
|
| 19 |
+
"@hookform/resolvers": "^5.1.1",
|
| 20 |
+
"@mdxeditor/editor": "^3.39.1",
|
| 21 |
+
"@prisma/client": "^6.11.1",
|
| 22 |
+
"@radix-ui/react-accordion": "^1.2.11",
|
| 23 |
+
"@radix-ui/react-alert-dialog": "^1.1.14",
|
| 24 |
+
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
| 25 |
+
"@radix-ui/react-avatar": "^1.1.10",
|
| 26 |
+
"@radix-ui/react-checkbox": "^1.3.2",
|
| 27 |
+
"@radix-ui/react-collapsible": "^1.1.11",
|
| 28 |
+
"@radix-ui/react-context-menu": "^2.2.15",
|
| 29 |
+
"@radix-ui/react-dialog": "^1.1.14",
|
| 30 |
+
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
| 31 |
+
"@radix-ui/react-hover-card": "^1.1.14",
|
| 32 |
+
"@radix-ui/react-label": "^2.1.7",
|
| 33 |
+
"@radix-ui/react-menubar": "^1.1.15",
|
| 34 |
+
"@radix-ui/react-navigation-menu": "^1.2.13",
|
| 35 |
+
"@radix-ui/react-popover": "^1.1.14",
|
| 36 |
+
"@radix-ui/react-progress": "^1.1.7",
|
| 37 |
+
"@radix-ui/react-radio-group": "^1.3.7",
|
| 38 |
+
"@radix-ui/react-scroll-area": "^1.2.9",
|
| 39 |
+
"@radix-ui/react-select": "^2.2.5",
|
| 40 |
+
"@radix-ui/react-separator": "^1.1.7",
|
| 41 |
+
"@radix-ui/react-slider": "^1.3.5",
|
| 42 |
+
"@radix-ui/react-slot": "^1.2.3",
|
| 43 |
+
"@radix-ui/react-switch": "^1.2.5",
|
| 44 |
+
"@radix-ui/react-tabs": "^1.1.12",
|
| 45 |
+
"@radix-ui/react-toast": "^1.2.14",
|
| 46 |
+
"@radix-ui/react-toggle": "^1.1.9",
|
| 47 |
+
"@radix-ui/react-toggle-group": "^1.1.10",
|
| 48 |
+
"@radix-ui/react-tooltip": "^1.2.7",
|
| 49 |
+
"@reactuses/core": "^6.0.5",
|
| 50 |
+
"@tanstack/react-query": "^5.82.0",
|
| 51 |
+
"@tanstack/react-table": "^8.21.3",
|
| 52 |
+
"class-variance-authority": "^0.7.1",
|
| 53 |
+
"clsx": "^2.1.1",
|
| 54 |
+
"cmdk": "^1.1.1",
|
| 55 |
+
"date-fns": "^4.1.0",
|
| 56 |
+
"embla-carousel-react": "^8.6.0",
|
| 57 |
+
"framer-motion": "^12.23.2",
|
| 58 |
+
"input-otp": "^1.4.2",
|
| 59 |
+
"lucide-react": "^0.525.0",
|
| 60 |
+
"next": "^16.1.1",
|
| 61 |
+
"next-auth": "^4.24.11",
|
| 62 |
+
"next-intl": "^4.3.4",
|
| 63 |
+
"next-themes": "^0.4.6",
|
| 64 |
+
"prisma": "^6.11.1",
|
| 65 |
+
"react": "^19.0.0",
|
| 66 |
+
"react-day-picker": "^9.8.0",
|
| 67 |
+
"react-dom": "^19.0.0",
|
| 68 |
+
"react-hook-form": "^7.60.0",
|
| 69 |
+
"react-markdown": "^10.1.0",
|
| 70 |
+
"react-resizable-panels": "^3.0.3",
|
| 71 |
+
"react-syntax-highlighter": "^15.6.1",
|
| 72 |
+
"recharts": "^2.15.4",
|
| 73 |
+
"sharp": "^0.34.3",
|
| 74 |
+
"sonner": "^2.0.6",
|
| 75 |
+
"tailwind-merge": "^3.3.1",
|
| 76 |
+
"tailwindcss-animate": "^1.0.7",
|
| 77 |
+
"uuid": "^11.1.0",
|
| 78 |
+
"vaul": "^1.1.2",
|
| 79 |
+
"z-ai-web-dev-sdk": "^0.0.16",
|
| 80 |
+
"zod": "^4.0.2",
|
| 81 |
+
"zustand": "^5.0.6"
|
| 82 |
+
},
|
| 83 |
+
"devDependencies": {
|
| 84 |
+
"@tailwindcss/postcss": "^4",
|
| 85 |
+
"@types/react": "^19",
|
| 86 |
+
"@types/react-dom": "^19",
|
| 87 |
+
"bun-types": "^1.3.4",
|
| 88 |
+
"eslint": "^9",
|
| 89 |
+
"eslint-config-next": "^16.1.1",
|
| 90 |
+
"tailwindcss": "^4",
|
| 91 |
+
"tw-animate-css": "^1.3.5",
|
| 92 |
+
"typescript": "^5"
|
| 93 |
+
}
|
| 94 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: ["@tailwindcss/postcss"],
|
| 3 |
+
};
|
| 4 |
+
|
| 5 |
+
export default config;
|
prisma/schema.prisma
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This is your Prisma schema file,
|
| 2 |
+
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
| 3 |
+
|
| 4 |
+
generator client {
|
| 5 |
+
provider = "prisma-client-js"
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
datasource db {
|
| 9 |
+
provider = "sqlite"
|
| 10 |
+
url = env("DATABASE_URL")
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// ============================================
|
| 14 |
+
// MODELOS BASE
|
| 15 |
+
// ============================================
|
| 16 |
+
|
| 17 |
+
model User {
|
| 18 |
+
id String @id @default(cuid())
|
| 19 |
+
email String @unique
|
| 20 |
+
name String?
|
| 21 |
+
createdAt DateTime @default(now())
|
| 22 |
+
updatedAt DateTime @updatedAt
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
model Project {
|
| 26 |
+
id String @id @default(cuid())
|
| 27 |
+
name String
|
| 28 |
+
description String?
|
| 29 |
+
style String @default("default")
|
| 30 |
+
status String @default("active")
|
| 31 |
+
createdAt DateTime @default(now())
|
| 32 |
+
updatedAt DateTime @updatedAt
|
| 33 |
+
repos Repo[]
|
| 34 |
+
analyses Analysis[]
|
| 35 |
+
contents Content[]
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
model Repo {
|
| 39 |
+
id String @id @default(cuid())
|
| 40 |
+
url String
|
| 41 |
+
name String
|
| 42 |
+
status String @default("cloned")
|
| 43 |
+
projectId String?
|
| 44 |
+
project Project? @relation(fields: [projectId], references: [id])
|
| 45 |
+
analyses Analysis[]
|
| 46 |
+
createdAt DateTime @default(now())
|
| 47 |
+
updatedAt DateTime @updatedAt
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
model Analysis {
|
| 51 |
+
id String @id @default(cuid())
|
| 52 |
+
type String
|
| 53 |
+
result String
|
| 54 |
+
summary String?
|
| 55 |
+
repoId String?
|
| 56 |
+
repo Repo? @relation(fields: [repoId], references: [id])
|
| 57 |
+
projectId String?
|
| 58 |
+
project Project? @relation(fields: [projectId], references: [id])
|
| 59 |
+
createdAt DateTime @default(now())
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
model AgentTask {
|
| 63 |
+
id String @id @default(cuid())
|
| 64 |
+
type String
|
| 65 |
+
status String @default("pending")
|
| 66 |
+
input String
|
| 67 |
+
output String?
|
| 68 |
+
createdAt DateTime @default(now())
|
| 69 |
+
completedAt DateTime?
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// ============================================
|
| 73 |
+
// MODELOS DE CONTENIDO MULTIMEDIA
|
| 74 |
+
// ============================================
|
| 75 |
+
|
| 76 |
+
model Content {
|
| 77 |
+
id String @id @default(cuid())
|
| 78 |
+
type String // "image", "video", "audio", "text", "reel", "story", "carousel"
|
| 79 |
+
title String
|
| 80 |
+
description String?
|
| 81 |
+
prompt String
|
| 82 |
+
optimizedPrompt String?
|
| 83 |
+
filePath String?
|
| 84 |
+
thumbnail String?
|
| 85 |
+
platform String @default("general")
|
| 86 |
+
status String @default("pending")
|
| 87 |
+
metadata String?
|
| 88 |
+
projectId String?
|
| 89 |
+
project Project? @relation(fields: [projectId], references: [id])
|
| 90 |
+
characterId String?
|
| 91 |
+
character Character? @relation(fields: [characterId], references: [id])
|
| 92 |
+
petId String?
|
| 93 |
+
pet Pet? @relation(fields: [petId], references: [id])
|
| 94 |
+
censorFlags CensorFlag[]
|
| 95 |
+
posts Post[]
|
| 96 |
+
createdAt DateTime @default(now())
|
| 97 |
+
updatedAt DateTime @updatedAt
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
model Character {
|
| 101 |
+
id String @id @default(cuid())
|
| 102 |
+
name String
|
| 103 |
+
description String?
|
| 104 |
+
referenceImage String?
|
| 105 |
+
traits String?
|
| 106 |
+
contents Content[]
|
| 107 |
+
pets Pet[]
|
| 108 |
+
createdAt DateTime @default(now())
|
| 109 |
+
updatedAt DateTime @updatedAt
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// ============================================
|
| 113 |
+
// MODELOS DE CENSURA
|
| 114 |
+
// ============================================
|
| 115 |
+
|
| 116 |
+
model CensorRule {
|
| 117 |
+
id String @id @default(cuid())
|
| 118 |
+
platform String
|
| 119 |
+
category String
|
| 120 |
+
rule String
|
| 121 |
+
severity String @default("medium")
|
| 122 |
+
autoAction String @default("warn")
|
| 123 |
+
isActive Boolean @default(true)
|
| 124 |
+
createdAt DateTime @default(now())
|
| 125 |
+
updatedAt DateTime @updatedAt
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
model CensorFlag {
|
| 129 |
+
id String @id @default(cuid())
|
| 130 |
+
contentId String
|
| 131 |
+
content Content @relation(fields: [contentId], references: [id])
|
| 132 |
+
category String
|
| 133 |
+
reason String
|
| 134 |
+
severity String
|
| 135 |
+
action String
|
| 136 |
+
createdAt DateTime @default(now())
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// ============================================
|
| 140 |
+
// MODELOS DE MONETIZACIÓN
|
| 141 |
+
// ============================================
|
| 142 |
+
|
| 143 |
+
model MonetizationPlatform {
|
| 144 |
+
id String @id @default(cuid())
|
| 145 |
+
name String // OnlyFans, Patreon, Fansly, etc.
|
| 146 |
+
type String // "subscription", "tips", "ppv", "mixed"
|
| 147 |
+
url String?
|
| 148 |
+
apiKey String? // Encriptado
|
| 149 |
+
accountId String?
|
| 150 |
+
accountName String?
|
| 151 |
+
legalTerms String? // JSON con términos legales
|
| 152 |
+
contentRules String? // JSON con reglas de contenido
|
| 153 |
+
feePercentage Float?
|
| 154 |
+
payoutSchedule String?
|
| 155 |
+
isActive Boolean @default(true)
|
| 156 |
+
isVerified Boolean @default(false)
|
| 157 |
+
metadata String?
|
| 158 |
+
posts Post[]
|
| 159 |
+
earnings Earning[]
|
| 160 |
+
subscribers Subscriber[]
|
| 161 |
+
createdAt DateTime @default(now())
|
| 162 |
+
updatedAt DateTime @updatedAt
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
model Subscriber {
|
| 166 |
+
id String @id @default(cuid())
|
| 167 |
+
platformId String
|
| 168 |
+
platform MonetizationPlatform @relation(fields: [platformId], references: [id])
|
| 169 |
+
externalId String?
|
| 170 |
+
username String?
|
| 171 |
+
tier String? // Nivel de suscripción
|
| 172 |
+
status String @default("active") // active, expired, cancelled
|
| 173 |
+
joinedAt DateTime?
|
| 174 |
+
expiresAt DateTime?
|
| 175 |
+
totalSpent Float @default(0)
|
| 176 |
+
metadata String?
|
| 177 |
+
createdAt DateTime @default(now())
|
| 178 |
+
updatedAt DateTime @updatedAt
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
model Earning {
|
| 182 |
+
id String @id @default(cuid())
|
| 183 |
+
platformId String
|
| 184 |
+
platform MonetizationPlatform @relation(fields: [platformId], references: [id])
|
| 185 |
+
type String // subscription, tip, ppv, referral
|
| 186 |
+
amount Float
|
| 187 |
+
currency String @default("USD")
|
| 188 |
+
postId String?
|
| 189 |
+
subscriberId String?
|
| 190 |
+
status String @default("pending") // pending, processed, paid
|
| 191 |
+
processedAt DateTime?
|
| 192 |
+
metadata String?
|
| 193 |
+
createdAt DateTime @default(now())
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// ============================================
|
| 197 |
+
// MODELOS DE PUBLICACIÓN
|
| 198 |
+
// ============================================
|
| 199 |
+
|
| 200 |
+
model Post {
|
| 201 |
+
id String @id @default(cuid())
|
| 202 |
+
title String?
|
| 203 |
+
caption String?
|
| 204 |
+
hashtags String? // JSON array
|
| 205 |
+
type String // reel, photo, carousel, story, post
|
| 206 |
+
status String @default("draft") // draft, scheduled, published, failed
|
| 207 |
+
contentId String?
|
| 208 |
+
content Content? @relation(fields: [contentId], references: [id])
|
| 209 |
+
platformId String?
|
| 210 |
+
platform MonetizationPlatform? @relation(fields: [platformId], references: [id])
|
| 211 |
+
scheduledAt DateTime?
|
| 212 |
+
publishedAt DateTime?
|
| 213 |
+
externalPostId String? // ID en la plataforma externa
|
| 214 |
+
postUrl String? // URL del post publicado
|
| 215 |
+
engagementStats String? // JSON con likes, views, shares, etc.
|
| 216 |
+
storyId String?
|
| 217 |
+
story Story? @relation(fields: [storyId], references: [id])
|
| 218 |
+
metadata String?
|
| 219 |
+
createdAt DateTime @default(now())
|
| 220 |
+
updatedAt DateTime @updatedAt
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// ============================================
|
| 224 |
+
// MODELOS DE STORYTELLING
|
| 225 |
+
// ============================================
|
| 226 |
+
|
| 227 |
+
model Story {
|
| 228 |
+
id String @id @default(cuid())
|
| 229 |
+
title String
|
| 230 |
+
description String?
|
| 231 |
+
genre String? // romance, drama, comedy, thriller, etc.
|
| 232 |
+
targetAudience String? // Demografía objetivo
|
| 233 |
+
tone String? // romantic, funny, dramatic, mysterious
|
| 234 |
+
structure String? // JSON con estructura narrativa
|
| 235 |
+
characterIds String? // JSON array de IDs de personajes
|
| 236 |
+
totalEpisodes Int @default(1)
|
| 237 |
+
currentEpisode Int @default(1)
|
| 238 |
+
status String @default("draft") // draft, active, completed, paused
|
| 239 |
+
monetizationStrategy String? // JSON con estrategia de monetización
|
| 240 |
+
posts Post[]
|
| 241 |
+
episodes StoryEpisode[]
|
| 242 |
+
analytics StoryAnalytics?
|
| 243 |
+
createdAt DateTime @default(now())
|
| 244 |
+
updatedAt DateTime @updatedAt
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
model StoryEpisode {
|
| 248 |
+
id String @id @default(cuid())
|
| 249 |
+
storyId String
|
| 250 |
+
story Story @relation(fields: [storyId], references: [id])
|
| 251 |
+
episodeNum Int
|
| 252 |
+
title String
|
| 253 |
+
synopsis String?
|
| 254 |
+
content String // Guión o descripción detallada
|
| 255 |
+
hook String? // Gancho para la siguiente episodio
|
| 256 |
+
cliffhanger String?
|
| 257 |
+
status String @default("draft")
|
| 258 |
+
scheduledAt DateTime?
|
| 259 |
+
publishedAt DateTime?
|
| 260 |
+
createdAt DateTime @default(now())
|
| 261 |
+
updatedAt DateTime @updatedAt
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
model StoryAnalytics {
|
| 265 |
+
id String @id @default(cuid())
|
| 266 |
+
storyId String @unique
|
| 267 |
+
story Story @relation(fields: [storyId], references: [id])
|
| 268 |
+
totalViews Int @default(0)
|
| 269 |
+
totalEngagement Float @default(0)
|
| 270 |
+
avgWatchTime Float? // En segundos
|
| 271 |
+
completionRate Float? // Porcentaje
|
| 272 |
+
revenue Float @default(0)
|
| 273 |
+
subscriberGain Int @default(0)
|
| 274 |
+
bestPerformingEpisode Int?
|
| 275 |
+
metadata String?
|
| 276 |
+
createdAt DateTime @default(now())
|
| 277 |
+
updatedAt DateTime @updatedAt
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// ============================================
|
| 281 |
+
// MODELOS DE AUTOMATIZACIÓN
|
| 282 |
+
// ============================================
|
| 283 |
+
|
| 284 |
+
model Automation {
|
| 285 |
+
id String @id @default(cuid())
|
| 286 |
+
name String
|
| 287 |
+
description String?
|
| 288 |
+
type String // content_generation, posting, engagement, monetization
|
| 289 |
+
trigger String // schedule, event, manual, webhook
|
| 290 |
+
triggerConfig String? // JSON con configuración del trigger
|
| 291 |
+
actions String // JSON array de acciones a ejecutar
|
| 292 |
+
isActive Boolean @default(true)
|
| 293 |
+
lastRunAt DateTime?
|
| 294 |
+
nextRunAt DateTime?
|
| 295 |
+
runCount Int @default(0)
|
| 296 |
+
metadata String?
|
| 297 |
+
createdAt DateTime @default(now())
|
| 298 |
+
updatedAt DateTime @updatedAt
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
model AutomationLog {
|
| 302 |
+
id String @id @default(cuid())
|
| 303 |
+
automationId String?
|
| 304 |
+
status String // success, failed, partial
|
| 305 |
+
input String?
|
| 306 |
+
output String?
|
| 307 |
+
error String?
|
| 308 |
+
duration Int? // Milisegundos
|
| 309 |
+
createdAt DateTime @default(now())
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// ============================================
|
| 313 |
+
// MODELOS DE TENDENCIAS
|
| 314 |
+
// ============================================
|
| 315 |
+
|
| 316 |
+
model Trend {
|
| 317 |
+
id String @id @default(cuid())
|
| 318 |
+
platform String
|
| 319 |
+
type String // hashtag, sound, challenge, topic
|
| 320 |
+
name String
|
| 321 |
+
description String?
|
| 322 |
+
volume Int?
|
| 323 |
+
growth Float?
|
| 324 |
+
startDate DateTime?
|
| 325 |
+
endDate DateTime?
|
| 326 |
+
relatedTags String? // JSON array
|
| 327 |
+
contentIdeas String? // JSON con ideas de contenido
|
| 328 |
+
isActive Boolean @default(true)
|
| 329 |
+
createdAt DateTime @default(now())
|
| 330 |
+
updatedAt DateTime @updatedAt
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// ============================================
|
| 334 |
+
// MODELOS DE PLANTILLAS
|
| 335 |
+
// ============================================
|
| 336 |
+
|
| 337 |
+
model PromptTemplate {
|
| 338 |
+
id String @id @default(cuid())
|
| 339 |
+
name String
|
| 340 |
+
category String
|
| 341 |
+
template String
|
| 342 |
+
variables String
|
| 343 |
+
platform String @default("general")
|
| 344 |
+
isActive Boolean @default(true)
|
| 345 |
+
createdAt DateTime @default(now())
|
| 346 |
+
updatedAt DateTime @updatedAt
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
model ContentTemplate {
|
| 350 |
+
id String @id @default(cuid())
|
| 351 |
+
name String
|
| 352 |
+
type String // reel, carousel, story, post
|
| 353 |
+
platform String
|
| 354 |
+
structure String // JSON con estructura del contenido
|
| 355 |
+
hooks String? // JSON array de ganchos
|
| 356 |
+
ctas String? // JSON array de call-to-actions
|
| 357 |
+
hashtags String? // JSON array de hashtags
|
| 358 |
+
bestTimes String? // JSON con mejores horarios
|
| 359 |
+
isActive Boolean @default(true)
|
| 360 |
+
createdAt DateTime @default(now())
|
| 361 |
+
updatedAt DateTime @updatedAt
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// ============================================
|
| 365 |
+
// MODELOS DE MASCOTAS/COMPAÑEROS
|
| 366 |
+
// ============================================
|
| 367 |
+
|
| 368 |
+
model Pet {
|
| 369 |
+
id String @id @default(cuid())
|
| 370 |
+
name String
|
| 371 |
+
type String // dog, cat, bird, etc.
|
| 372 |
+
breed String?
|
| 373 |
+
description String?
|
| 374 |
+
referenceImage String?
|
| 375 |
+
traits String? // JSON con características
|
| 376 |
+
personality String? // playful, calm, energetic
|
| 377 |
+
color String?
|
| 378 |
+
accessories String? // JSON array de accesorios
|
| 379 |
+
characterId String?
|
| 380 |
+
character Character? @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
| 381 |
+
contents Content[]
|
| 382 |
+
isActive Boolean @default(true)
|
| 383 |
+
createdAt DateTime @default(now())
|
| 384 |
+
updatedAt DateTime @updatedAt
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
// ============================================
|
| 388 |
+
// MODELOS DE INFLUENCERS IA DE REFERENCIA
|
| 389 |
+
// ============================================
|
| 390 |
+
|
| 391 |
+
model AIInfluencer {
|
| 392 |
+
id String @id @default(cuid())
|
| 393 |
+
name String
|
| 394 |
+
handle String?
|
| 395 |
+
platform String
|
| 396 |
+
followers Int?
|
| 397 |
+
engagement Float?
|
| 398 |
+
niche String?
|
| 399 |
+
style String? // JSON con estilo de contenido
|
| 400 |
+
contentTypes String? // JSON array de tipos de contenido
|
| 401 |
+
postingSchedule String? // JSON con horarios
|
| 402 |
+
visualStyle String? // JSON con estilo visual
|
| 403 |
+
monetizationType String? // JSON con tipo de monetización
|
| 404 |
+
signatureElements String? // JSON con elementos distintivos
|
| 405 |
+
petCompanion Boolean @default(false)
|
| 406 |
+
petType String?
|
| 407 |
+
analysis String? // JSON con análisis detallado
|
| 408 |
+
lessons String? // JSON con lecciones aprendidas
|
| 409 |
+
isActive Boolean @default(true)
|
| 410 |
+
createdAt DateTime @default(now())
|
| 411 |
+
updatedAt DateTime @updatedAt
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
// ============================================
|
| 415 |
+
// MODELOS DE ESTRATEGIAS VIRALES
|
| 416 |
+
// ============================================
|
| 417 |
+
|
| 418 |
+
model ViralStrategy {
|
| 419 |
+
id String @id @default(cuid())
|
| 420 |
+
name String
|
| 421 |
+
description String?
|
| 422 |
+
platform String
|
| 423 |
+
contentType String // reel, post, story, carousel
|
| 424 |
+
hook String? // Gancho inicial
|
| 425 |
+
structure String? // JSON con estructura viral
|
| 426 |
+
elements String? // JSON con elementos clave
|
| 427 |
+
estimatedReach Int?
|
| 428 |
+
difficulty String @default("medium")
|
| 429 |
+
timeframe String? // Tiempo para viralizar
|
| 430 |
+
examples String? // JSON con ejemplos
|
| 431 |
+
tags String? // JSON array de tags
|
| 432 |
+
successRate Float?
|
| 433 |
+
isActive Boolean @default(true)
|
| 434 |
+
createdAt DateTime @default(now())
|
| 435 |
+
updatedAt DateTime @updatedAt
|
| 436 |
+
}
|
public/logo.svg
ADDED
|
|
public/robots.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
User-agent: Googlebot
|
| 2 |
+
Allow: /
|
| 3 |
+
|
| 4 |
+
User-agent: Bingbot
|
| 5 |
+
Allow: /
|
| 6 |
+
|
| 7 |
+
User-agent: Twitterbot
|
| 8 |
+
Allow: /
|
| 9 |
+
|
| 10 |
+
User-agent: facebookexternalhit
|
| 11 |
+
Allow: /
|
| 12 |
+
|
| 13 |
+
User-agent: *
|
| 14 |
+
Allow: /
|
src/app/api/agent/route.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 4 |
+
|
| 5 |
+
const SYSTEM_PROMPT = `Eres Sofía, un asistente de desarrollo de software altamente inteligente y especializado. Tu nombre es Sofía y eres parte del sistema multiagente "Sofía Cloud".
|
| 6 |
+
|
| 7 |
+
Tus capacidades incluyen:
|
| 8 |
+
- Análisis de código y repositorios de GitHub
|
| 9 |
+
- Generación de código y documentación
|
| 10 |
+
- Sugerencias de mejoras y optimizaciones
|
| 11 |
+
- Detección de bugs y vulnerabilidades
|
| 12 |
+
- Creación de arquitecturas de software
|
| 13 |
+
- Explicaciones técnicas claras y detalladas
|
| 14 |
+
|
| 15 |
+
Responde siempre de manera profesional, útil y con un toque amigable. Cuando analices código, sé específico y proporciona ejemplos concretos. Si detectas problemas, sugiere soluciones prácticas.
|
| 16 |
+
|
| 17 |
+
Formato de respuestas:
|
| 18 |
+
- Usa Markdown para formatear tus respuestas
|
| 19 |
+
- Incluye bloques de código cuando sea relevante
|
| 20 |
+
- Sé conciso pero completo
|
| 21 |
+
- Si el usuario pregunta algo fuera del ámbito técnico, redirige amablemente al tema de desarrollo`;
|
| 22 |
+
|
| 23 |
+
// POST - Enviar prompt al agente IA
|
| 24 |
+
export async function POST(request: NextRequest) {
|
| 25 |
+
try {
|
| 26 |
+
const body = await request.json();
|
| 27 |
+
const { prompt, context } = body;
|
| 28 |
+
|
| 29 |
+
if (!prompt) {
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ success: false, error: "El prompt es requerido" },
|
| 32 |
+
{ status: 400 }
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Crear tarea pendiente
|
| 37 |
+
const task = await db.agentTask.create({
|
| 38 |
+
data: {
|
| 39 |
+
type: "analyze",
|
| 40 |
+
status: "pending",
|
| 41 |
+
input: prompt,
|
| 42 |
+
},
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
try {
|
| 46 |
+
// Usar z-ai-web-dev-sdk para la respuesta de IA
|
| 47 |
+
const zai = await ZAI.create();
|
| 48 |
+
|
| 49 |
+
const messages = [
|
| 50 |
+
{ role: "system" as const, content: SYSTEM_PROMPT },
|
| 51 |
+
{ role: "user" as const, content: context ? `Contexto: ${context}\n\nPregunta: ${prompt}` : prompt },
|
| 52 |
+
];
|
| 53 |
+
|
| 54 |
+
const completion = await zai.chat.completions.create({
|
| 55 |
+
messages,
|
| 56 |
+
temperature: 0.7,
|
| 57 |
+
max_tokens: 4000,
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
const response = completion.choices[0]?.message?.content || "No se pudo generar una respuesta";
|
| 61 |
+
|
| 62 |
+
// Actualizar tarea como completada
|
| 63 |
+
await db.agentTask.update({
|
| 64 |
+
where: { id: task.id },
|
| 65 |
+
data: {
|
| 66 |
+
status: "completed",
|
| 67 |
+
output: response,
|
| 68 |
+
completedAt: new Date(),
|
| 69 |
+
},
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
return NextResponse.json({
|
| 73 |
+
success: true,
|
| 74 |
+
response,
|
| 75 |
+
taskId: task.id,
|
| 76 |
+
});
|
| 77 |
+
} catch (aiError) {
|
| 78 |
+
console.error("AI Error:", aiError);
|
| 79 |
+
|
| 80 |
+
// Actualizar tarea como fallida
|
| 81 |
+
await db.agentTask.update({
|
| 82 |
+
where: { id: task.id },
|
| 83 |
+
data: {
|
| 84 |
+
status: "failed",
|
| 85 |
+
output: aiError instanceof Error ? aiError.message : "Error desconocido",
|
| 86 |
+
},
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
return NextResponse.json(
|
| 90 |
+
{ success: false, error: "Error al procesar con IA" },
|
| 91 |
+
{ status: 500 }
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
} catch (error) {
|
| 95 |
+
console.error("Error in agent route:", error);
|
| 96 |
+
return NextResponse.json(
|
| 97 |
+
{ success: false, error: "Error interno del servidor" },
|
| 98 |
+
{ status: 500 }
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// GET - Obtener historial de tareas
|
| 104 |
+
export async function GET() {
|
| 105 |
+
try {
|
| 106 |
+
const tasks = await db.agentTask.findMany({
|
| 107 |
+
orderBy: { createdAt: "desc" },
|
| 108 |
+
take: 20,
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
return NextResponse.json({
|
| 112 |
+
success: true,
|
| 113 |
+
tasks,
|
| 114 |
+
});
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error("Error fetching tasks:", error);
|
| 117 |
+
return NextResponse.json(
|
| 118 |
+
{ success: false, error: "Error al obtener tareas" },
|
| 119 |
+
{ status: 500 }
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
}
|
src/app/api/analyze/route.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 4 |
+
import fs from "fs/promises";
|
| 5 |
+
import path from "path";
|
| 6 |
+
|
| 7 |
+
const REPOS_DIR = path.join(process.cwd(), "repos");
|
| 8 |
+
|
| 9 |
+
async function readRepoFiles(repoName: string, maxFiles: number = 10): Promise<string> {
|
| 10 |
+
const repoPath = path.join(REPOS_DIR, repoName);
|
| 11 |
+
let content = "";
|
| 12 |
+
let fileCount = 0;
|
| 13 |
+
|
| 14 |
+
async function readDir(dir: string, depth: number = 0): Promise<void> {
|
| 15 |
+
if (fileCount >= maxFiles || depth > 3) return;
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
| 19 |
+
|
| 20 |
+
for (const entry of entries) {
|
| 21 |
+
if (fileCount >= maxFiles) break;
|
| 22 |
+
|
| 23 |
+
const fullPath = path.join(dir, entry.name);
|
| 24 |
+
|
| 25 |
+
// Ignorar carpetas comunes
|
| 26 |
+
if (entry.isDirectory()) {
|
| 27 |
+
if (["node_modules", ".git", "dist", "build", "__pycache__", "venv", ".next"].includes(entry.name)) {
|
| 28 |
+
continue;
|
| 29 |
+
}
|
| 30 |
+
await readDir(fullPath, depth + 1);
|
| 31 |
+
} else if (entry.isFile()) {
|
| 32 |
+
// Solo archivos de código relevantes
|
| 33 |
+
const ext = path.extname(entry.name);
|
| 34 |
+
const codeExtensions = [".ts", ".tsx", ".js", ".jsx", ".py", ".java", ".go", ".rs", ".c", ".cpp", ".h", ".css", ".scss", ".html", ".json", ".yaml", ".yml", ".md"];
|
| 35 |
+
|
| 36 |
+
if (codeExtensions.includes(ext) && !entry.name.startsWith(".")) {
|
| 37 |
+
try {
|
| 38 |
+
const fileContent = await fs.readFile(fullPath, "utf-8");
|
| 39 |
+
const relativePath = path.relative(repoPath, fullPath);
|
| 40 |
+
content += `\n--- ${relativePath} ---\n${fileContent.slice(0, 2000)}\n`;
|
| 41 |
+
fileCount++;
|
| 42 |
+
} catch {
|
| 43 |
+
// Archivo no legible
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
} catch {
|
| 49 |
+
// Directorio no accesible
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
await readDir(repoPath);
|
| 54 |
+
return content;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// POST - Analizar código con IA
|
| 58 |
+
export async function POST(request: NextRequest) {
|
| 59 |
+
try {
|
| 60 |
+
const body = await request.json();
|
| 61 |
+
const { repoId, projectId, type, code } = body;
|
| 62 |
+
|
| 63 |
+
let analysisContent = code || "";
|
| 64 |
+
let repoName = "";
|
| 65 |
+
|
| 66 |
+
// Si hay repoId, leer archivos del repositorio
|
| 67 |
+
if (repoId) {
|
| 68 |
+
const repo = await db.repo.findUnique({
|
| 69 |
+
where: { id: repoId },
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
if (repo) {
|
| 73 |
+
repoName = repo.name;
|
| 74 |
+
analysisContent = await readRepoFiles(repo.name);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (!analysisContent) {
|
| 79 |
+
return NextResponse.json(
|
| 80 |
+
{ success: false, error: "No hay código para analizar" },
|
| 81 |
+
{ status: 400 }
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const analysisType = type || "code";
|
| 86 |
+
|
| 87 |
+
// Crear análisis inicial
|
| 88 |
+
const analysis = await db.analysis.create({
|
| 89 |
+
data: {
|
| 90 |
+
type: analysisType,
|
| 91 |
+
result: "Analizando...",
|
| 92 |
+
repoId: repoId || null,
|
| 93 |
+
projectId: projectId || null,
|
| 94 |
+
},
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const zai = await ZAI.create();
|
| 99 |
+
|
| 100 |
+
const prompts: Record<string, string> = {
|
| 101 |
+
code: `Analiza el siguiente código y proporciona:
|
| 102 |
+
1. Resumen general del proyecto
|
| 103 |
+
2. Estructura y organización
|
| 104 |
+
3. Calidad del código (0-10)
|
| 105 |
+
4. Posibles mejoras
|
| 106 |
+
5. Bugs o problemas potenciales
|
| 107 |
+
6. Sugerencias de seguridad
|
| 108 |
+
|
| 109 |
+
Código a analizar:
|
| 110 |
+
${analysisContent.slice(0, 10000)}`,
|
| 111 |
+
|
| 112 |
+
security: `Realiza un análisis de seguridad del siguiente código. Identifica:
|
| 113 |
+
1. Vulnerabilidades potenciales (SQL injection, XSS, etc.)
|
| 114 |
+
2. Exposición de datos sensibles
|
| 115 |
+
3. Dependencias inseguras
|
| 116 |
+
4. Configuraciones peligrosas
|
| 117 |
+
5. Recomendaciones de mitigación
|
| 118 |
+
|
| 119 |
+
Código:
|
| 120 |
+
${analysisContent.slice(0, 10000)}`,
|
| 121 |
+
|
| 122 |
+
performance: `Analiza el rendimiento del siguiente código:
|
| 123 |
+
1. Cuellos de botella potenciales
|
| 124 |
+
2. Complejidad algorítmica
|
| 125 |
+
3. Uso de memoria
|
| 126 |
+
4. Operaciones bloqueantes
|
| 127 |
+
5. Optimizaciones sugeridas
|
| 128 |
+
|
| 129 |
+
Código:
|
| 130 |
+
${analysisContent.slice(0, 10000)}`,
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const completion = await zai.chat.completions.create({
|
| 134 |
+
messages: [
|
| 135 |
+
{
|
| 136 |
+
role: "system",
|
| 137 |
+
content: "Eres un experto en análisis de código. Proporciona análisis detallados y accionables.",
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
role: "user",
|
| 141 |
+
content: prompts[analysisType] || prompts.code,
|
| 142 |
+
},
|
| 143 |
+
],
|
| 144 |
+
temperature: 0.3,
|
| 145 |
+
max_tokens: 4000,
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
const result = completion.choices[0]?.message?.content || "No se pudo generar análisis";
|
| 149 |
+
|
| 150 |
+
// Extraer resumen (primeras 200 caracteres)
|
| 151 |
+
const summary = result.slice(0, 200) + "...";
|
| 152 |
+
|
| 153 |
+
// Actualizar análisis
|
| 154 |
+
const updatedAnalysis = await db.analysis.update({
|
| 155 |
+
where: { id: analysis.id },
|
| 156 |
+
data: {
|
| 157 |
+
result,
|
| 158 |
+
summary,
|
| 159 |
+
},
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
// Crear tarea de agente
|
| 163 |
+
await db.agentTask.create({
|
| 164 |
+
data: {
|
| 165 |
+
type: "analyze",
|
| 166 |
+
status: "completed",
|
| 167 |
+
input: `Analizar ${repoName || "código"} (${analysisType})`,
|
| 168 |
+
output: summary,
|
| 169 |
+
completedAt: new Date(),
|
| 170 |
+
},
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
return NextResponse.json({
|
| 174 |
+
success: true,
|
| 175 |
+
analysis: updatedAnalysis,
|
| 176 |
+
message: "Análisis completado",
|
| 177 |
+
});
|
| 178 |
+
} catch (aiError) {
|
| 179 |
+
console.error("AI Analysis Error:", aiError);
|
| 180 |
+
|
| 181 |
+
await db.analysis.update({
|
| 182 |
+
where: { id: analysis.id },
|
| 183 |
+
data: {
|
| 184 |
+
result: "Error en el análisis: " + (aiError instanceof Error ? aiError.message : "Error desconocido"),
|
| 185 |
+
},
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
return NextResponse.json(
|
| 189 |
+
{ success: false, error: "Error en el análisis de IA" },
|
| 190 |
+
{ status: 500 }
|
| 191 |
+
);
|
| 192 |
+
}
|
| 193 |
+
} catch (error) {
|
| 194 |
+
console.error("Error in analyze route:", error);
|
| 195 |
+
return NextResponse.json(
|
| 196 |
+
{ success: false, error: "Error interno del servidor" },
|
| 197 |
+
{ status: 500 }
|
| 198 |
+
);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// GET - Obtener análisis
|
| 203 |
+
export async function GET(request: NextRequest) {
|
| 204 |
+
try {
|
| 205 |
+
const { searchParams } = new URL(request.url);
|
| 206 |
+
const repoId = searchParams.get("repoId");
|
| 207 |
+
const projectId = searchParams.get("projectId");
|
| 208 |
+
|
| 209 |
+
const where: Record<string, string> = {};
|
| 210 |
+
if (repoId) where.repoId = repoId;
|
| 211 |
+
if (projectId) where.projectId = projectId;
|
| 212 |
+
|
| 213 |
+
const analyses = await db.analysis.findMany({
|
| 214 |
+
where: Object.keys(where).length > 0 ? where : undefined,
|
| 215 |
+
include: {
|
| 216 |
+
repo: true,
|
| 217 |
+
project: true,
|
| 218 |
+
},
|
| 219 |
+
orderBy: { createdAt: "desc" },
|
| 220 |
+
take: 20,
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
return NextResponse.json({
|
| 224 |
+
success: true,
|
| 225 |
+
analyses,
|
| 226 |
+
});
|
| 227 |
+
} catch (error) {
|
| 228 |
+
console.error("Error fetching analyses:", error);
|
| 229 |
+
return NextResponse.json(
|
| 230 |
+
{ success: false, error: "Error al obtener análisis" },
|
| 231 |
+
{ status: 500 }
|
| 232 |
+
);
|
| 233 |
+
}
|
| 234 |
+
}
|
src/app/api/automation/route.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 4 |
+
|
| 5 |
+
// Tipos de automatización disponibles
|
| 6 |
+
const AUTOMATION_TYPES = {
|
| 7 |
+
content_generation: {
|
| 8 |
+
name: "Generación de Contenido",
|
| 9 |
+
description: "Genera contenido automáticamente basado en tendencias y programación",
|
| 10 |
+
triggers: ["schedule", "trend_detected", "manual"]
|
| 11 |
+
},
|
| 12 |
+
posting: {
|
| 13 |
+
name: "Publicación Automática",
|
| 14 |
+
description: "Publica contenido en horarios óptimos",
|
| 15 |
+
triggers: ["schedule", "content_ready", "manual"]
|
| 16 |
+
},
|
| 17 |
+
engagement: {
|
| 18 |
+
name: "Engagement Automático",
|
| 19 |
+
description: "Responde comentarios y mensajes automáticamente",
|
| 20 |
+
triggers: ["new_comment", "new_message", "schedule"]
|
| 21 |
+
},
|
| 22 |
+
monetization: {
|
| 23 |
+
name: "Optimización de Monetización",
|
| 24 |
+
description: "Ajusta precios y ofertas basado en analytics",
|
| 25 |
+
triggers: ["analytics_update", "subscriber_change", "schedule"]
|
| 26 |
+
},
|
| 27 |
+
storytelling: {
|
| 28 |
+
name: "Storytelling Continuo",
|
| 29 |
+
description: "Continúa historias automáticamente con cliffhangers",
|
| 30 |
+
triggers: ["episode_published", "engagement_threshold", "schedule"]
|
| 31 |
+
},
|
| 32 |
+
cross_posting: {
|
| 33 |
+
name: "Cross-Posting",
|
| 34 |
+
description: "Publica el mismo contenido en múltiples plataformas",
|
| 35 |
+
triggers: ["content_published", "schedule", "manual"]
|
| 36 |
+
},
|
| 37 |
+
trend_tracking: {
|
| 38 |
+
name: "Seguimiento de Tendencias",
|
| 39 |
+
description: "Detecta tendencias y sugiere contenido",
|
| 40 |
+
triggers: ["schedule", "manual"]
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
// GET - Listar automatizaciones
|
| 45 |
+
export async function GET(request: NextRequest) {
|
| 46 |
+
try {
|
| 47 |
+
const { searchParams } = new URL(request.url);
|
| 48 |
+
const type = searchParams.get("type");
|
| 49 |
+
const isActive = searchParams.get("isActive");
|
| 50 |
+
|
| 51 |
+
const where: Record<string, unknown> = {};
|
| 52 |
+
if (type) where.type = type;
|
| 53 |
+
if (isActive !== null) where.isActive = isActive === "true";
|
| 54 |
+
|
| 55 |
+
const automations = await db.automation.findMany({
|
| 56 |
+
where,
|
| 57 |
+
include: {
|
| 58 |
+
_count: {
|
| 59 |
+
select: { logs: true }
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
orderBy: { createdAt: "desc" }
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
// Últimos logs
|
| 66 |
+
const recentLogs = await db.automationLog.findMany({
|
| 67 |
+
orderBy: { createdAt: "desc" },
|
| 68 |
+
take: 20,
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// Estadísticas
|
| 72 |
+
const stats = {
|
| 73 |
+
total: await db.automation.count(),
|
| 74 |
+
active: await db.automation.count({ where: { isActive: true } }),
|
| 75 |
+
runsToday: await db.automationLog.count({
|
| 76 |
+
where: {
|
| 77 |
+
createdAt: {
|
| 78 |
+
gte: new Date(new Date().setHours(0, 0, 0, 0))
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
}),
|
| 82 |
+
successRate: await calculateSuccessRate()
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
return NextResponse.json({
|
| 86 |
+
success: true,
|
| 87 |
+
automations,
|
| 88 |
+
automationTypes: AUTOMATION_TYPES,
|
| 89 |
+
recentLogs,
|
| 90 |
+
stats
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error("Error fetching automations:", error);
|
| 95 |
+
return NextResponse.json(
|
| 96 |
+
{ success: false, error: "Error al obtener automatizaciones" },
|
| 97 |
+
{ status: 500 }
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// POST - Crear automatización
|
| 103 |
+
export async function POST(request: NextRequest) {
|
| 104 |
+
try {
|
| 105 |
+
const body = await request.json();
|
| 106 |
+
const {
|
| 107 |
+
name,
|
| 108 |
+
description,
|
| 109 |
+
type,
|
| 110 |
+
trigger,
|
| 111 |
+
triggerConfig,
|
| 112 |
+
actions
|
| 113 |
+
} = body;
|
| 114 |
+
|
| 115 |
+
if (!name || !type || !trigger || !actions) {
|
| 116 |
+
return NextResponse.json(
|
| 117 |
+
{ success: false, error: "Faltan campos requeridos: name, type, trigger, actions" },
|
| 118 |
+
{ status: 400 }
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Validar tipo de automatización
|
| 123 |
+
if (!AUTOMATION_TYPES[type as keyof typeof AUTOMATION_TYPES]) {
|
| 124 |
+
return NextResponse.json(
|
| 125 |
+
{ success: false, error: "Tipo de automatización no válido" },
|
| 126 |
+
{ status: 400 }
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Calcular próxima ejecución si es programada
|
| 131 |
+
let nextRunAt = null;
|
| 132 |
+
if (trigger === "schedule" && triggerConfig?.schedule) {
|
| 133 |
+
nextRunAt = calculateNextRun(triggerConfig.schedule);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const automation = await db.automation.create({
|
| 137 |
+
data: {
|
| 138 |
+
name,
|
| 139 |
+
description: description || null,
|
| 140 |
+
type,
|
| 141 |
+
trigger,
|
| 142 |
+
triggerConfig: triggerConfig ? JSON.stringify(triggerConfig) : null,
|
| 143 |
+
actions: JSON.stringify(actions),
|
| 144 |
+
nextRunAt,
|
| 145 |
+
isActive: true,
|
| 146 |
+
}
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
return NextResponse.json({
|
| 150 |
+
success: true,
|
| 151 |
+
automation,
|
| 152 |
+
message: `Automatización "${name}" creada`
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
} catch (error) {
|
| 156 |
+
console.error("Error creating automation:", error);
|
| 157 |
+
return NextResponse.json(
|
| 158 |
+
{ success: false, error: "Error al crear automatización" },
|
| 159 |
+
{ status: 500 }
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// PUT - Ejecutar o actualizar automatización
|
| 165 |
+
export async function PUT(request: NextRequest) {
|
| 166 |
+
try {
|
| 167 |
+
const body = await request.json();
|
| 168 |
+
const { id, action, isActive, triggerConfig, actions } = body;
|
| 169 |
+
|
| 170 |
+
if (!id) {
|
| 171 |
+
return NextResponse.json(
|
| 172 |
+
{ success: false, error: "ID requerido" },
|
| 173 |
+
{ status: 400 }
|
| 174 |
+
);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// Si es una acción de ejecución
|
| 178 |
+
if (action === "execute") {
|
| 179 |
+
return await executeAutomation(id);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// Actualización normal
|
| 183 |
+
const updateData: Record<string, unknown> = {};
|
| 184 |
+
if (isActive !== undefined) updateData.isActive = isActive;
|
| 185 |
+
if (triggerConfig) updateData.triggerConfig = JSON.stringify(triggerConfig);
|
| 186 |
+
if (actions) updateData.actions = JSON.stringify(actions);
|
| 187 |
+
|
| 188 |
+
const automation = await db.automation.update({
|
| 189 |
+
where: { id },
|
| 190 |
+
data: updateData
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
return NextResponse.json({
|
| 194 |
+
success: true,
|
| 195 |
+
automation
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
} catch (error) {
|
| 199 |
+
console.error("Error updating automation:", error);
|
| 200 |
+
return NextResponse.json(
|
| 201 |
+
{ success: false, error: "Error al actualizar automatización" },
|
| 202 |
+
{ status: 500 }
|
| 203 |
+
);
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// DELETE - Eliminar automatización
|
| 208 |
+
export async function DELETE(request: NextRequest) {
|
| 209 |
+
try {
|
| 210 |
+
const { searchParams } = new URL(request.url);
|
| 211 |
+
const id = searchParams.get("id");
|
| 212 |
+
|
| 213 |
+
if (!id) {
|
| 214 |
+
return NextResponse.json(
|
| 215 |
+
{ success: false, error: "ID requerido" },
|
| 216 |
+
{ status: 400 }
|
| 217 |
+
);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// Eliminar logs primero
|
| 221 |
+
await db.automationLog.deleteMany({
|
| 222 |
+
where: { automationId: id }
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
await db.automation.delete({
|
| 226 |
+
where: { id }
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
return NextResponse.json({
|
| 230 |
+
success: true,
|
| 231 |
+
message: "Automatización eliminada"
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
} catch (error) {
|
| 235 |
+
console.error("Error deleting automation:", error);
|
| 236 |
+
return NextResponse.json(
|
| 237 |
+
{ success: false, error: "Error al eliminar automatización" },
|
| 238 |
+
{ status: 500 }
|
| 239 |
+
);
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
// Función para ejecutar una automatización
|
| 244 |
+
async function executeAutomation(automationId: string) {
|
| 245 |
+
const startTime = Date.now();
|
| 246 |
+
|
| 247 |
+
try {
|
| 248 |
+
const automation = await db.automation.findUnique({
|
| 249 |
+
where: { id: automationId }
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
if (!automation) {
|
| 253 |
+
return NextResponse.json(
|
| 254 |
+
{ success: false, error: "Automatización no encontrada" },
|
| 255 |
+
{ status: 404 }
|
| 256 |
+
);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
const actions = JSON.parse(automation.actions);
|
| 260 |
+
const results: unknown[] = [];
|
| 261 |
+
|
| 262 |
+
// Ejecutar cada acción
|
| 263 |
+
for (const action of actions) {
|
| 264 |
+
const result = await executeAction(action);
|
| 265 |
+
results.push(result);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Actualizar última ejecución
|
| 269 |
+
await db.automation.update({
|
| 270 |
+
where: { id: automationId },
|
| 271 |
+
data: {
|
| 272 |
+
lastRunAt: new Date(),
|
| 273 |
+
runCount: { increment: 1 },
|
| 274 |
+
nextRunAt: automation.trigger === "schedule"
|
| 275 |
+
? calculateNextRun(JSON.parse(automation.triggerConfig || "{}").schedule)
|
| 276 |
+
: null
|
| 277 |
+
}
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
// Crear log de éxito
|
| 281 |
+
await db.automationLog.create({
|
| 282 |
+
data: {
|
| 283 |
+
automationId,
|
| 284 |
+
status: "success",
|
| 285 |
+
input: automation.actions,
|
| 286 |
+
output: JSON.stringify(results),
|
| 287 |
+
duration: Date.now() - startTime
|
| 288 |
+
}
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
return NextResponse.json({
|
| 292 |
+
success: true,
|
| 293 |
+
results,
|
| 294 |
+
duration: Date.now() - startTime
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
} catch (error) {
|
| 298 |
+
// Crear log de error
|
| 299 |
+
await db.automationLog.create({
|
| 300 |
+
data: {
|
| 301 |
+
automationId,
|
| 302 |
+
status: "failed",
|
| 303 |
+
error: String(error),
|
| 304 |
+
duration: Date.now() - startTime
|
| 305 |
+
}
|
| 306 |
+
});
|
| 307 |
+
|
| 308 |
+
return NextResponse.json(
|
| 309 |
+
{ success: false, error: "Error al ejecutar automatización" },
|
| 310 |
+
{ status: 500 }
|
| 311 |
+
);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// Ejecutar acción individual
|
| 316 |
+
async function executeAction(action: Record<string, unknown>): Promise<unknown> {
|
| 317 |
+
const zai = await ZAI.create();
|
| 318 |
+
|
| 319 |
+
switch (action.type) {
|
| 320 |
+
case "generate_content":
|
| 321 |
+
// Generar contenido con IA
|
| 322 |
+
const completion = await zai.chat.completions.create({
|
| 323 |
+
messages: [
|
| 324 |
+
{ role: "system", content: "Eres un generador de contenido para redes sociales." },
|
| 325 |
+
{ role: "user", content: String(action.prompt) }
|
| 326 |
+
]
|
| 327 |
+
});
|
| 328 |
+
return { type: "content", result: completion.choices[0]?.message?.content };
|
| 329 |
+
|
| 330 |
+
case "create_post":
|
| 331 |
+
// Crear post programado
|
| 332 |
+
const post = await db.post.create({
|
| 333 |
+
data: {
|
| 334 |
+
title: String(action.title) || "Auto-generated",
|
| 335 |
+
caption: String(action.caption) || "",
|
| 336 |
+
type: String(action.postType) || "post",
|
| 337 |
+
status: "draft"
|
| 338 |
+
}
|
| 339 |
+
});
|
| 340 |
+
return { type: "post", postId: post.id };
|
| 341 |
+
|
| 342 |
+
case "check_trends":
|
| 343 |
+
// Simular detección de tendencias
|
| 344 |
+
return { type: "trends", trends: ["trending1", "trending2"] };
|
| 345 |
+
|
| 346 |
+
case "send_notification":
|
| 347 |
+
return { type: "notification", sent: true };
|
| 348 |
+
|
| 349 |
+
default:
|
| 350 |
+
return { type: action.type, status: "executed" };
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// Calcular próxima ejecución
|
| 355 |
+
function calculateNextRun(schedule: string): Date {
|
| 356 |
+
const now = new Date();
|
| 357 |
+
|
| 358 |
+
// Parsear schedule simple (ej: "daily:09:00", "hourly", "weekly:monday:10:00")
|
| 359 |
+
if (schedule.startsWith("daily:")) {
|
| 360 |
+
const time = schedule.split(":")[1];
|
| 361 |
+
const [hours, minutes] = time.split("-").length > 1
|
| 362 |
+
? time.split("-")[0].split(":").map(Number)
|
| 363 |
+
: time.split(":").map(Number);
|
| 364 |
+
|
| 365 |
+
const next = new Date(now);
|
| 366 |
+
next.setHours(hours || 9, minutes || 0, 0, 0);
|
| 367 |
+
if (next <= now) next.setDate(next.getDate() + 1);
|
| 368 |
+
return next;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
if (schedule === "hourly") {
|
| 372 |
+
return new Date(now.getTime() + 60 * 60 * 1000);
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
if (schedule === "daily") {
|
| 376 |
+
const next = new Date(now);
|
| 377 |
+
next.setDate(next.getDate() + 1);
|
| 378 |
+
next.setHours(9, 0, 0, 0);
|
| 379 |
+
return next;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
// Default: 1 hora
|
| 383 |
+
return new Date(now.getTime() + 60 * 60 * 1000);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
// Calcular tasa de éxito
|
| 387 |
+
async function calculateSuccessRate(): Promise<number> {
|
| 388 |
+
const total = await db.automationLog.count();
|
| 389 |
+
if (total === 0) return 100;
|
| 390 |
+
|
| 391 |
+
const success = await db.automationLog.count({
|
| 392 |
+
where: { status: "success" }
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
return Math.round((success / total) * 100);
|
| 396 |
+
}
|
src/app/api/censor/route.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 4 |
+
|
| 5 |
+
// Reglas de censura por plataforma y categoría
|
| 6 |
+
const DEFAULT_CENSOR_RULES = [
|
| 7 |
+
// YouTube
|
| 8 |
+
{ platform: "youtube", category: "nudity", rule: "No se permite desnudez ni contenido sexual", severity: "high", autoAction: "remove" },
|
| 9 |
+
{ platform: "youtube", category: "violence", rule: "Violencia gráfica no permitida sin advertencia", severity: "medium", autoAction: "warn" },
|
| 10 |
+
{ platform: "youtube", category: "hate_speech", rule: "No se permite discurso de odio", severity: "high", autoAction: "remove" },
|
| 11 |
+
|
| 12 |
+
// TikTok
|
| 13 |
+
{ platform: "tiktok", category: "nudity", rule: "Cero tolerancia para desnudez", severity: "high", autoAction: "remove" },
|
| 14 |
+
{ platform: "tiktok", category: "violence", rule: "Sin violencia gráfica", severity: "high", autoAction: "remove" },
|
| 15 |
+
{ platform: "tiktok", category: "self_harm", rule: "No contenido de autolesión", severity: "high", autoAction: "remove" },
|
| 16 |
+
{ platform: "tiktok", category: "dangerous", rule: "No desafíos peligrosos", severity: "high", autoAction: "remove" },
|
| 17 |
+
|
| 18 |
+
// Instagram
|
| 19 |
+
{ platform: "instagram", category: "nudity", rule: "No desnudez, excepto arte con moderación", severity: "high", autoAction: "blur" },
|
| 20 |
+
{ platform: "instagram", category: "violence", rule: "Sin violencia gráfica", severity: "high", autoAction: "remove" },
|
| 21 |
+
|
| 22 |
+
// Twitter/X
|
| 23 |
+
{ platform: "twitter", category: "illegal", rule: "No contenido ilegal", severity: "high", autoAction: "remove" },
|
| 24 |
+
{ platform: "twitter", category: "sensitive", rule: "Marcar como contenido sensible", severity: "low", autoAction: "warn" },
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
// GET - Obtener reglas de censura
|
| 28 |
+
export async function GET(request: NextRequest) {
|
| 29 |
+
try {
|
| 30 |
+
const { searchParams } = new URL(request.url);
|
| 31 |
+
const platform = searchParams.get("platform");
|
| 32 |
+
|
| 33 |
+
let rules;
|
| 34 |
+
if (platform) {
|
| 35 |
+
rules = await db.censorRule.findMany({
|
| 36 |
+
where: { platform, isActive: true }
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
// Si no hay reglas en BD, usar las por defecto
|
| 40 |
+
if (rules.length === 0) {
|
| 41 |
+
rules = DEFAULT_CENSOR_RULES.filter(r => r.platform === platform);
|
| 42 |
+
}
|
| 43 |
+
} else {
|
| 44 |
+
rules = await db.censorRule.findMany({
|
| 45 |
+
where: { isActive: true }
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
if (rules.length === 0) {
|
| 49 |
+
rules = DEFAULT_CENSOR_RULES;
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return NextResponse.json({
|
| 54 |
+
success: true,
|
| 55 |
+
rules
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
} catch (error) {
|
| 59 |
+
console.error("Error fetching censor rules:", error);
|
| 60 |
+
return NextResponse.json(
|
| 61 |
+
{ success: false, error: "Error al obtener reglas de censura" },
|
| 62 |
+
{ status: 500 }
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// POST - Analizar contenido para censura
|
| 68 |
+
export async function POST(request: NextRequest) {
|
| 69 |
+
try {
|
| 70 |
+
const body = await request.json();
|
| 71 |
+
const { content, type, platform } = body;
|
| 72 |
+
|
| 73 |
+
if (!content) {
|
| 74 |
+
return NextResponse.json(
|
| 75 |
+
{ success: false, error: "Contenido requerido para análisis" },
|
| 76 |
+
{ status: 400 }
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const zai = await ZAI.create();
|
| 81 |
+
|
| 82 |
+
// Usar IA para analizar el contenido
|
| 83 |
+
const analysisPrompt = `Analiza el siguiente ${type || "contenido"} para verificar si cumple con las normas de la plataforma ${platform || "general"}.
|
| 84 |
+
|
| 85 |
+
Contenido a analizar:
|
| 86 |
+
${content.substring(0, 2000)}
|
| 87 |
+
|
| 88 |
+
Responde SOLO en formato JSON:
|
| 89 |
+
{
|
| 90 |
+
"safe": true/false,
|
| 91 |
+
"flags": [
|
| 92 |
+
{"category": "categoría", "reason": "razón", "severity": "low/medium/high"}
|
| 93 |
+
],
|
| 94 |
+
"recommendations": ["recomendación1", "recomendación2"],
|
| 95 |
+
"platformCompatible": ["plataforma1", "plataforma2"]
|
| 96 |
+
}`;
|
| 97 |
+
|
| 98 |
+
const completion = await zai.chat.completions.create({
|
| 99 |
+
messages: [
|
| 100 |
+
{
|
| 101 |
+
role: "system",
|
| 102 |
+
content: `Eres un moderador de contenido experto. Tu trabajo es analizar contenido para verificar si cumple con las políticas de diferentes plataformas. Sé estricto pero justo. Considera:
|
| 103 |
+
|
| 104 |
+
1. YouTube: Familiar, sin desnudez, violencia moderada con advertencia
|
| 105 |
+
2. TikTok: Para todos los públicos, sin contenido peligroso
|
| 106 |
+
3. Instagram: Sin desnudez (excepto arte), sin violencia gráfica
|
| 107 |
+
4. Twitter: Más permisivo pero marca contenido sensible
|
| 108 |
+
5. General: Apropiado para todo público`
|
| 109 |
+
},
|
| 110 |
+
{ role: "user", content: analysisPrompt }
|
| 111 |
+
],
|
| 112 |
+
temperature: 0.3,
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
const response = completion.choices[0]?.message?.content || "";
|
| 116 |
+
|
| 117 |
+
// Parsear respuesta
|
| 118 |
+
let analysis;
|
| 119 |
+
try {
|
| 120 |
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
| 121 |
+
if (jsonMatch) {
|
| 122 |
+
analysis = JSON.parse(jsonMatch[0]);
|
| 123 |
+
} else {
|
| 124 |
+
analysis = {
|
| 125 |
+
safe: true,
|
| 126 |
+
flags: [],
|
| 127 |
+
recommendations: [],
|
| 128 |
+
platformCompatible: [platform || "general"]
|
| 129 |
+
};
|
| 130 |
+
}
|
| 131 |
+
} catch {
|
| 132 |
+
analysis = {
|
| 133 |
+
safe: true,
|
| 134 |
+
flags: [],
|
| 135 |
+
recommendations: [],
|
| 136 |
+
platformCompatible: [platform || "general"]
|
| 137 |
+
};
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
return NextResponse.json({
|
| 141 |
+
success: true,
|
| 142 |
+
analysis,
|
| 143 |
+
originalContent: content.substring(0, 200)
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
} catch (error) {
|
| 147 |
+
console.error("Error analyzing content:", error);
|
| 148 |
+
return NextResponse.json(
|
| 149 |
+
{ success: false, error: "Error al analizar contenido" },
|
| 150 |
+
{ status: 500 }
|
| 151 |
+
);
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// PUT - Añadir regla de censura personalizada
|
| 156 |
+
export async function PUT(request: NextRequest) {
|
| 157 |
+
try {
|
| 158 |
+
const body = await request.json();
|
| 159 |
+
const { platform, category, rule, severity, autoAction } = body;
|
| 160 |
+
|
| 161 |
+
if (!platform || !category || !rule) {
|
| 162 |
+
return NextResponse.json(
|
| 163 |
+
{ success: false, error: "Plataforma, categoría y regla son requeridos" },
|
| 164 |
+
{ status: 400 }
|
| 165 |
+
);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const censorRule = await db.censorRule.create({
|
| 169 |
+
data: {
|
| 170 |
+
platform,
|
| 171 |
+
category,
|
| 172 |
+
rule,
|
| 173 |
+
severity: severity || "medium",
|
| 174 |
+
autoAction: autoAction || "warn"
|
| 175 |
+
}
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
return NextResponse.json({
|
| 179 |
+
success: true,
|
| 180 |
+
rule: censorRule,
|
| 181 |
+
message: "Regla de censura creada"
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
} catch (error) {
|
| 185 |
+
console.error("Error creating censor rule:", error);
|
| 186 |
+
return NextResponse.json(
|
| 187 |
+
{ success: false, error: "Error al crear regla de censura" },
|
| 188 |
+
{ status: 500 }
|
| 189 |
+
);
|
| 190 |
+
}
|
| 191 |
+
}
|
src/app/api/characters/route.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 4 |
+
import fs from "fs/promises";
|
| 5 |
+
import path from "path";
|
| 6 |
+
|
| 7 |
+
const CHARACTERS_DIR = path.join(process.cwd(), "download", "characters");
|
| 8 |
+
|
| 9 |
+
async function ensureDir() {
|
| 10 |
+
try {
|
| 11 |
+
await fs.mkdir(CHARACTERS_DIR, { recursive: true });
|
| 12 |
+
} catch {
|
| 13 |
+
// ya existe
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// GET - Listar personajes
|
| 18 |
+
export async function GET(request: NextRequest) {
|
| 19 |
+
try {
|
| 20 |
+
const characters = await db.character.findMany({
|
| 21 |
+
include: {
|
| 22 |
+
contents: {
|
| 23 |
+
take: 5,
|
| 24 |
+
orderBy: { createdAt: "desc" }
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
orderBy: { createdAt: "desc" }
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
return NextResponse.json({
|
| 31 |
+
success: true,
|
| 32 |
+
characters,
|
| 33 |
+
total: characters.length
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
} catch (error) {
|
| 37 |
+
console.error("Error fetching characters:", error);
|
| 38 |
+
return NextResponse.json(
|
| 39 |
+
{ success: false, error: "Error al obtener personajes" },
|
| 40 |
+
{ status: 500 }
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// POST - Crear personaje
|
| 46 |
+
export async function POST(request: NextRequest) {
|
| 47 |
+
try {
|
| 48 |
+
const body = await request.json();
|
| 49 |
+
const {
|
| 50 |
+
name,
|
| 51 |
+
description,
|
| 52 |
+
generateReference = false,
|
| 53 |
+
traits
|
| 54 |
+
} = body;
|
| 55 |
+
|
| 56 |
+
if (!name) {
|
| 57 |
+
return NextResponse.json(
|
| 58 |
+
{ success: false, error: "El nombre del personaje es requerido" },
|
| 59 |
+
{ status: 400 }
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
await ensureDir();
|
| 64 |
+
|
| 65 |
+
let referenceImage = null;
|
| 66 |
+
let characterTraits = traits;
|
| 67 |
+
|
| 68 |
+
// Generar imagen de referencia si se solicita
|
| 69 |
+
if (generateReference) {
|
| 70 |
+
try {
|
| 71 |
+
const zai = await ZAI.create();
|
| 72 |
+
|
| 73 |
+
// Crear prompt detallado para el personaje
|
| 74 |
+
const characterPrompt = `Character reference sheet: ${name}. ${description || "Original character"}. Full body view, multiple angles (front, side, back), consistent character design, clear details, neutral pose, white background, character turnaround, reference sheet style. Professional character design, consistent facial features, consistent body proportions.`;
|
| 75 |
+
|
| 76 |
+
const response = await zai.images.generations.create({
|
| 77 |
+
prompt: characterPrompt,
|
| 78 |
+
size: "1344x768" // Horizontal para el reference sheet
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
const imageBase64 = response.data[0]?.base64;
|
| 82 |
+
if (imageBase64) {
|
| 83 |
+
const filename = `character_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}.png`;
|
| 84 |
+
const filepath = path.join(CHARACTERS_DIR, filename);
|
| 85 |
+
const buffer = Buffer.from(imageBase64, "base64");
|
| 86 |
+
await fs.writeFile(filepath, buffer);
|
| 87 |
+
referenceImage = `/download/characters/${filename}`;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Si no se proporcionaron traits, extraerlos con IA
|
| 91 |
+
if (!traits) {
|
| 92 |
+
const traitsResponse = await zai.chat.completions.create({
|
| 93 |
+
messages: [
|
| 94 |
+
{
|
| 95 |
+
role: "system",
|
| 96 |
+
content: "Eres un experto en diseño de personajes. Extrae los rasgos físicos clave del personaje para mantener consistencia en futuras generaciones. Responde SOLO en formato JSON con: { \"face\": \"\", \"body\": \"\", \"hair\": \"\", \"clothing\": \"\", \"colors\": \"\", \"distinctive\": \"\" }"
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
role: "user",
|
| 100 |
+
content: `Personaje: ${name}. Descripción: ${description || "Original character"}`
|
| 101 |
+
}
|
| 102 |
+
]
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
const traitsText = traitsResponse.choices[0]?.message?.content || "";
|
| 106 |
+
try {
|
| 107 |
+
const jsonMatch = traitsText.match(/\{[\s\S]*\}/);
|
| 108 |
+
if (jsonMatch) {
|
| 109 |
+
characterTraits = jsonMatch[0];
|
| 110 |
+
}
|
| 111 |
+
} catch {
|
| 112 |
+
characterTraits = JSON.stringify({ description: description || "Original character" });
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
} catch (genError) {
|
| 117 |
+
console.error("Error generando referencia:", genError);
|
| 118 |
+
// Continuar sin imagen de referencia
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Crear personaje en BD
|
| 123 |
+
const character = await db.character.create({
|
| 124 |
+
data: {
|
| 125 |
+
name,
|
| 126 |
+
description: description || null,
|
| 127 |
+
referenceImage,
|
| 128 |
+
traits: characterTraits || JSON.stringify({ name, description })
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
return NextResponse.json({
|
| 133 |
+
success: true,
|
| 134 |
+
character,
|
| 135 |
+
message: `Personaje "${name}" creado exitosamente`
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
} catch (error) {
|
| 139 |
+
console.error("Error creating character:", error);
|
| 140 |
+
return NextResponse.json(
|
| 141 |
+
{ success: false, error: "Error al crear personaje" },
|
| 142 |
+
{ status: 500 }
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// PUT - Actualizar personaje
|
| 148 |
+
export async function PUT(request: NextRequest) {
|
| 149 |
+
try {
|
| 150 |
+
const body = await request.json();
|
| 151 |
+
const { id, name, description, traits } = body;
|
| 152 |
+
|
| 153 |
+
if (!id) {
|
| 154 |
+
return NextResponse.json(
|
| 155 |
+
{ success: false, error: "ID del personaje requerido" },
|
| 156 |
+
{ status: 400 }
|
| 157 |
+
);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const character = await db.character.update({
|
| 161 |
+
where: { id },
|
| 162 |
+
data: {
|
| 163 |
+
name: name || undefined,
|
| 164 |
+
description: description || undefined,
|
| 165 |
+
traits: traits || undefined,
|
| 166 |
+
}
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
return NextResponse.json({
|
| 170 |
+
success: true,
|
| 171 |
+
character
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
} catch (error) {
|
| 175 |
+
console.error("Error updating character:", error);
|
| 176 |
+
return NextResponse.json(
|
| 177 |
+
{ success: false, error: "Error al actualizar personaje" },
|
| 178 |
+
{ status: 500 }
|
| 179 |
+
);
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// DELETE - Eliminar personaje
|
| 184 |
+
export async function DELETE(request: NextRequest) {
|
| 185 |
+
try {
|
| 186 |
+
const { searchParams } = new URL(request.url);
|
| 187 |
+
const id = searchParams.get("id");
|
| 188 |
+
|
| 189 |
+
if (!id) {
|
| 190 |
+
return NextResponse.json(
|
| 191 |
+
{ success: false, error: "ID requerido" },
|
| 192 |
+
{ status: 400 }
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
await db.character.delete({
|
| 197 |
+
where: { id }
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
return NextResponse.json({
|
| 201 |
+
success: true,
|
| 202 |
+
message: "Personaje eliminado"
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
} catch (error) {
|
| 206 |
+
console.error("Error deleting character:", error);
|
| 207 |
+
return NextResponse.json(
|
| 208 |
+
{ success: false, error: "Error al eliminar personaje" },
|
| 209 |
+
{ status: 500 }
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
}
|
src/app/api/content/route.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
// GET - Listar todo el contenido
|
| 5 |
+
export async function GET(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const { searchParams } = new URL(request.url);
|
| 8 |
+
const type = searchParams.get("type");
|
| 9 |
+
const platform = searchParams.get("platform");
|
| 10 |
+
const status = searchParams.get("status");
|
| 11 |
+
const limit = parseInt(searchParams.get("limit") || "50");
|
| 12 |
+
|
| 13 |
+
const where: Record<string, unknown> = {};
|
| 14 |
+
if (type) where.type = type;
|
| 15 |
+
if (platform) where.platform = platform;
|
| 16 |
+
if (status) where.status = status;
|
| 17 |
+
|
| 18 |
+
const contents = await db.content.findMany({
|
| 19 |
+
where,
|
| 20 |
+
include: {
|
| 21 |
+
character: true,
|
| 22 |
+
censorFlags: true,
|
| 23 |
+
},
|
| 24 |
+
orderBy: { createdAt: "desc" },
|
| 25 |
+
take: limit,
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
// Estadísticas
|
| 29 |
+
const stats = {
|
| 30 |
+
total: await db.content.count(),
|
| 31 |
+
images: await db.content.count({ where: { type: "image" } }),
|
| 32 |
+
videos: await db.content.count({ where: { type: "video" } }),
|
| 33 |
+
pending: await db.content.count({ where: { status: "pending" } }),
|
| 34 |
+
processing: await db.content.count({ where: { status: "processing" } }),
|
| 35 |
+
completed: await db.content.count({ where: { status: "completed" } }),
|
| 36 |
+
failed: await db.content.count({ where: { status: "failed" } }),
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
return NextResponse.json({
|
| 40 |
+
success: true,
|
| 41 |
+
contents,
|
| 42 |
+
stats
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error("Error fetching content:", error);
|
| 47 |
+
return NextResponse.json(
|
| 48 |
+
{ success: false, error: "Error al obtener contenido" },
|
| 49 |
+
{ status: 500 }
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// DELETE - Eliminar contenido
|
| 55 |
+
export async function DELETE(request: NextRequest) {
|
| 56 |
+
try {
|
| 57 |
+
const { searchParams } = new URL(request.url);
|
| 58 |
+
const id = searchParams.get("id");
|
| 59 |
+
|
| 60 |
+
if (!id) {
|
| 61 |
+
return NextResponse.json(
|
| 62 |
+
{ success: false, error: "ID requerido" },
|
| 63 |
+
{ status: 400 }
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Eliminar flags de censura asociados
|
| 68 |
+
await db.censorFlag.deleteMany({
|
| 69 |
+
where: { contentId: id }
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
// Eliminar contenido
|
| 73 |
+
await db.content.delete({
|
| 74 |
+
where: { id }
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
return NextResponse.json({
|
| 78 |
+
success: true,
|
| 79 |
+
message: "Contenido eliminado"
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error("Error deleting content:", error);
|
| 84 |
+
return NextResponse.json(
|
| 85 |
+
{ success: false, error: "Error al eliminar contenido" },
|
| 86 |
+
{ status: 500 }
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
}
|
src/app/api/generate/image/route.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
+
import { db } from "@/lib/db";
|
| 4 |
+
import fs from "fs/promises";
|
| 5 |
+
import path from "path";
|
| 6 |
+
|
| 7 |
+
const DOWNLOAD_DIR = path.join(process.cwd(), "download", "images");
|
| 8 |
+
|
| 9 |
+
// Asegurar directorio existe
|
| 10 |
+
async function ensureDir() {
|
| 11 |
+
try {
|
| 12 |
+
await fs.mkdir(DOWNLOAD_DIR, { recursive: true });
|
| 13 |
+
} catch {
|
| 14 |
+
// ya existe
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Reglas de censura por plataforma
|
| 19 |
+
const CENSOR_RULES: Record<string, string[]> = {
|
| 20 |
+
youtube: [
|
| 21 |
+
"no nudity",
|
| 22 |
+
"no sexual content",
|
| 23 |
+
"no graphic violence",
|
| 24 |
+
"family friendly"
|
| 25 |
+
],
|
| 26 |
+
tiktok: [
|
| 27 |
+
"no nudity",
|
| 28 |
+
"no sexual suggestiveness",
|
| 29 |
+
"no graphic violence",
|
| 30 |
+
"no self-harm content",
|
| 31 |
+
"age appropriate"
|
| 32 |
+
],
|
| 33 |
+
instagram: [
|
| 34 |
+
"no nudity",
|
| 35 |
+
"no graphic violence",
|
| 36 |
+
"artistic content acceptable with moderation",
|
| 37 |
+
"no self-harm imagery"
|
| 38 |
+
],
|
| 39 |
+
twitter: [
|
| 40 |
+
"content warning if sensitive",
|
| 41 |
+
"no illegal content"
|
| 42 |
+
],
|
| 43 |
+
general: [
|
| 44 |
+
"safe for work",
|
| 45 |
+
"no explicit content"
|
| 46 |
+
]
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// Aplicar filtros de censura al prompt
|
| 50 |
+
function applyCensorFilter(prompt: string, platform: string): string {
|
| 51 |
+
const rules = CENSOR_RULES[platform] || CENSOR_RULES.general;
|
| 52 |
+
const censorAdditions = rules.map(rule => `(${rule})`).join(", ");
|
| 53 |
+
return `${prompt}, ${censorAdditions}`;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export async function POST(request: NextRequest) {
|
| 57 |
+
try {
|
| 58 |
+
const body = await request.json();
|
| 59 |
+
const {
|
| 60 |
+
prompt,
|
| 61 |
+
optimizedPrompt,
|
| 62 |
+
platform = "general",
|
| 63 |
+
character,
|
| 64 |
+
style = "realistic",
|
| 65 |
+
size = "1024x1024",
|
| 66 |
+
saveToDb = true,
|
| 67 |
+
projectId
|
| 68 |
+
} = body;
|
| 69 |
+
|
| 70 |
+
if (!prompt && !optimizedPrompt) {
|
| 71 |
+
return NextResponse.json(
|
| 72 |
+
{ success: false, error: "Se requiere un prompt" },
|
| 73 |
+
{ status: 400 }
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
await ensureDir();
|
| 78 |
+
|
| 79 |
+
// Usar el prompt optimizado si está disponible
|
| 80 |
+
let finalPrompt = optimizedPrompt || prompt;
|
| 81 |
+
|
| 82 |
+
// Aplicar filtros de censura según plataforma
|
| 83 |
+
finalPrompt = applyCensorFilter(finalPrompt, platform);
|
| 84 |
+
|
| 85 |
+
// Añadir estilo si se especifica
|
| 86 |
+
if (style && style !== "realistic") {
|
| 87 |
+
const styleAdditions: Record<string, string> = {
|
| 88 |
+
anime: "anime style, manga aesthetic, vibrant colors, cel shading",
|
| 89 |
+
realistic: "photorealistic, highly detailed, 8K, professional photography",
|
| 90 |
+
artistic: "digital art, artistic style, creative interpretation, masterpiece",
|
| 91 |
+
"oil-painting": "oil painting style, classical art, brush strokes, museum quality",
|
| 92 |
+
sketch: "pencil sketch, hand drawn, artistic lines, detailed shading",
|
| 93 |
+
"3d": "3D render, octane render, cinema 4D, volumetric lighting"
|
| 94 |
+
};
|
| 95 |
+
if (styleAdditions[style]) {
|
| 96 |
+
finalPrompt = `${finalPrompt}, ${styleAdditions[style]}`;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Añadir referencia de personaje si existe
|
| 101 |
+
if (character) {
|
| 102 |
+
finalPrompt = `${finalPrompt}, character: ${character}`;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
console.log("🖼️ Generando imagen con prompt:", finalPrompt.substring(0, 100) + "...");
|
| 106 |
+
|
| 107 |
+
// Crear registro en BD si se solicita
|
| 108 |
+
let contentRecord = null;
|
| 109 |
+
if (saveToDb) {
|
| 110 |
+
contentRecord = await db.content.create({
|
| 111 |
+
data: {
|
| 112 |
+
type: "image",
|
| 113 |
+
title: prompt.substring(0, 50),
|
| 114 |
+
description: prompt,
|
| 115 |
+
prompt: prompt,
|
| 116 |
+
optimizedPrompt: finalPrompt,
|
| 117 |
+
platform: platform,
|
| 118 |
+
status: "processing",
|
| 119 |
+
projectId: projectId || null,
|
| 120 |
+
}
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
try {
|
| 125 |
+
// Generar imagen con z-ai-web-dev-sdk
|
| 126 |
+
const zai = await ZAI.create();
|
| 127 |
+
const response = await zai.images.generations.create({
|
| 128 |
+
prompt: finalPrompt,
|
| 129 |
+
size: size as "1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
const imageBase64 = response.data[0]?.base64;
|
| 133 |
+
|
| 134 |
+
if (!imageBase64) {
|
| 135 |
+
throw new Error("No se recibió imagen del generador");
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Guardar imagen en disco
|
| 139 |
+
const filename = `image_${Date.now()}.png`;
|
| 140 |
+
const filepath = path.join(DOWNLOAD_DIR, filename);
|
| 141 |
+
const buffer = Buffer.from(imageBase64, "base64");
|
| 142 |
+
await fs.writeFile(filepath, buffer);
|
| 143 |
+
|
| 144 |
+
// Actualizar registro en BD
|
| 145 |
+
if (contentRecord) {
|
| 146 |
+
await db.content.update({
|
| 147 |
+
where: { id: contentRecord.id },
|
| 148 |
+
data: {
|
| 149 |
+
status: "completed",
|
| 150 |
+
filePath: `/download/images/${filename}`,
|
| 151 |
+
thumbnail: `/download/images/${filename}`,
|
| 152 |
+
}
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// Crear tarea de agente
|
| 157 |
+
await db.agentTask.create({
|
| 158 |
+
data: {
|
| 159 |
+
type: "generate_image",
|
| 160 |
+
status: "completed",
|
| 161 |
+
input: prompt,
|
| 162 |
+
output: `Imagen generada: ${filename}`,
|
| 163 |
+
completedAt: new Date(),
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
return NextResponse.json({
|
| 168 |
+
success: true,
|
| 169 |
+
image: {
|
| 170 |
+
id: contentRecord?.id,
|
| 171 |
+
filename,
|
| 172 |
+
url: `/download/images/${filename}`,
|
| 173 |
+
base64: imageBase64.substring(0, 50) + "...", // Preview del base64
|
| 174 |
+
prompt: finalPrompt,
|
| 175 |
+
platform,
|
| 176 |
+
size
|
| 177 |
+
}
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
} catch (genError) {
|
| 181 |
+
console.error("Error generando imagen:", genError);
|
| 182 |
+
|
| 183 |
+
if (contentRecord) {
|
| 184 |
+
await db.content.update({
|
| 185 |
+
where: { id: contentRecord.id },
|
| 186 |
+
data: {
|
| 187 |
+
status: "failed",
|
| 188 |
+
metadata: JSON.stringify({ error: String(genError) })
|
| 189 |
+
}
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
return NextResponse.json(
|
| 194 |
+
{ success: false, error: "Error al generar imagen: " + String(genError) },
|
| 195 |
+
{ status: 500 }
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
} catch (error) {
|
| 200 |
+
console.error("Error en generate image:", error);
|
| 201 |
+
return NextResponse.json(
|
| 202 |
+
{ success: false, error: "Error interno del servidor" },
|
| 203 |
+
{ status: 500 }
|
| 204 |
+
);
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Listar imágenes generadas
|
| 209 |
+
export async function GET(request: NextRequest) {
|
| 210 |
+
try {
|
| 211 |
+
const { searchParams } = new URL(request.url);
|
| 212 |
+
const platform = searchParams.get("platform");
|
| 213 |
+
const limit = parseInt(searchParams.get("limit") || "20");
|
| 214 |
+
|
| 215 |
+
const where: Record<string, unknown> = { type: "image" };
|
| 216 |
+
if (platform) where.platform = platform;
|
| 217 |
+
|
| 218 |
+
const images = await db.content.findMany({
|
| 219 |
+
where,
|
| 220 |
+
orderBy: { createdAt: "desc" },
|
| 221 |
+
take: limit,
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
return NextResponse.json({
|
| 225 |
+
success: true,
|
| 226 |
+
images,
|
| 227 |
+
total: images.length
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
} catch (error) {
|
| 231 |
+
console.error("Error listing images:", error);
|
| 232 |
+
return NextResponse.json(
|
| 233 |
+
{ success: false, error: "Error al listar imágenes" },
|
| 234 |
+
{ status: 500 }
|
| 235 |
+
);
|
| 236 |
+
}
|
| 237 |
+
}
|
src/app/api/generate/video/route.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
+
import { db } from "@/lib/db";
|
| 4 |
+
import fs from "fs/promises";
|
| 5 |
+
import path from "path";
|
| 6 |
+
|
| 7 |
+
const DOWNLOAD_DIR = path.join(process.cwd(), "download", "videos");
|
| 8 |
+
|
| 9 |
+
async function ensureDir() {
|
| 10 |
+
try {
|
| 11 |
+
await fs.mkdir(DOWNLOAD_DIR, { recursive: true });
|
| 12 |
+
} catch {
|
| 13 |
+
// ya existe
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Reglas de censura para video
|
| 18 |
+
const VIDEO_CENSOR_RULES: Record<string, string> = {
|
| 19 |
+
youtube: "family friendly content, no violence, no nudity, safe for all ages",
|
| 20 |
+
tiktok: "age appropriate, no dangerous challenges, no suggestive content",
|
| 21 |
+
instagram: "community guidelines compliant, no graphic content, artistic nudity only with warning",
|
| 22 |
+
twitter: "may include sensitive content warning, no illegal content",
|
| 23 |
+
general: "safe for work, appropriate content"
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export async function POST(request: NextRequest) {
|
| 27 |
+
try {
|
| 28 |
+
const body = await request.json();
|
| 29 |
+
const {
|
| 30 |
+
prompt,
|
| 31 |
+
optimizedPrompt,
|
| 32 |
+
platform = "general",
|
| 33 |
+
style = "cinematic",
|
| 34 |
+
duration = "short",
|
| 35 |
+
aspectRatio = "16:9",
|
| 36 |
+
saveToDb = true,
|
| 37 |
+
projectId
|
| 38 |
+
} = body;
|
| 39 |
+
|
| 40 |
+
if (!prompt && !optimizedPrompt) {
|
| 41 |
+
return NextResponse.json(
|
| 42 |
+
{ success: false, error: "Se requiere un prompt para el video" },
|
| 43 |
+
{ status: 400 }
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
await ensureDir();
|
| 48 |
+
|
| 49 |
+
// Construir prompt final
|
| 50 |
+
let finalPrompt = optimizedPrompt || prompt;
|
| 51 |
+
|
| 52 |
+
// Añadir reglas de censura
|
| 53 |
+
const censorRule = VIDEO_CENSOR_RULES[platform] || VIDEO_CENSOR_RULES.general;
|
| 54 |
+
finalPrompt = `${finalPrompt}. Content requirements: ${censorRule}`;
|
| 55 |
+
|
| 56 |
+
// Añadir estilo de video
|
| 57 |
+
const videoStyles: Record<string, string> = {
|
| 58 |
+
cinematic: "cinematic quality, professional camera work, dramatic lighting",
|
| 59 |
+
documentary: "documentary style, natural lighting, authentic feel",
|
| 60 |
+
animation: "animated style, smooth motion, vibrant colors",
|
| 61 |
+
commercial: "commercial quality, polished, product showcase",
|
| 62 |
+
social: "social media optimized, engaging, quick cuts"
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
if (videoStyles[style]) {
|
| 66 |
+
finalPrompt = `${finalPrompt}, ${videoStyles[style]}`;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Añadir duración
|
| 70 |
+
const durationAdditions: Record<string, string> = {
|
| 71 |
+
short: "short clip, 5-10 seconds",
|
| 72 |
+
medium: "medium length, 15-30 seconds",
|
| 73 |
+
long: "extended content, 1-2 minutes"
|
| 74 |
+
};
|
| 75 |
+
finalPrompt = `${finalPrompt}, ${durationAdditions[duration] || durationAdditions.short}`;
|
| 76 |
+
|
| 77 |
+
console.log("🎥 Generando video con prompt:", finalPrompt.substring(0, 100) + "...");
|
| 78 |
+
|
| 79 |
+
// Crear registro en BD
|
| 80 |
+
let contentRecord = null;
|
| 81 |
+
if (saveToDb) {
|
| 82 |
+
contentRecord = await db.content.create({
|
| 83 |
+
data: {
|
| 84 |
+
type: "video",
|
| 85 |
+
title: prompt.substring(0, 50),
|
| 86 |
+
description: prompt,
|
| 87 |
+
prompt: prompt,
|
| 88 |
+
optimizedPrompt: finalPrompt,
|
| 89 |
+
platform: platform,
|
| 90 |
+
status: "processing",
|
| 91 |
+
projectId: projectId || null,
|
| 92 |
+
metadata: JSON.stringify({ style, duration, aspectRatio })
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const zai = await ZAI.create();
|
| 99 |
+
|
| 100 |
+
// Generar video con z-ai-web-dev-sdk
|
| 101 |
+
const response = await zai.videos.generations.create({
|
| 102 |
+
prompt: finalPrompt,
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
// El SDK de video devuelve información del video generado
|
| 106 |
+
const videoData = response.data?.[0];
|
| 107 |
+
|
| 108 |
+
if (!videoData) {
|
| 109 |
+
throw new Error("No se recibió video del generador");
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Guardar video si viene en base64 o URL
|
| 113 |
+
let filename = `video_${Date.now()}.mp4`;
|
| 114 |
+
let filepath = path.join(DOWNLOAD_DIR, filename);
|
| 115 |
+
let videoUrl = "";
|
| 116 |
+
|
| 117 |
+
if (videoData.base64) {
|
| 118 |
+
const buffer = Buffer.from(videoData.base64, "base64");
|
| 119 |
+
await fs.writeFile(filepath, buffer);
|
| 120 |
+
videoUrl = `/download/videos/${filename}`;
|
| 121 |
+
} else if (videoData.url) {
|
| 122 |
+
videoUrl = videoData.url;
|
| 123 |
+
filename = videoData.url.split("/").pop() || filename;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Actualizar registro
|
| 127 |
+
if (contentRecord) {
|
| 128 |
+
await db.content.update({
|
| 129 |
+
where: { id: contentRecord.id },
|
| 130 |
+
data: {
|
| 131 |
+
status: "completed",
|
| 132 |
+
filePath: videoUrl,
|
| 133 |
+
thumbnail: videoData.thumbnail || null,
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Crear tarea de agente
|
| 139 |
+
await db.agentTask.create({
|
| 140 |
+
data: {
|
| 141 |
+
type: "generate_video",
|
| 142 |
+
status: "completed",
|
| 143 |
+
input: prompt,
|
| 144 |
+
output: `Video generado: ${filename}`,
|
| 145 |
+
completedAt: new Date(),
|
| 146 |
+
}
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
return NextResponse.json({
|
| 150 |
+
success: true,
|
| 151 |
+
video: {
|
| 152 |
+
id: contentRecord?.id,
|
| 153 |
+
filename,
|
| 154 |
+
url: videoUrl,
|
| 155 |
+
thumbnail: videoData.thumbnail,
|
| 156 |
+
prompt: finalPrompt,
|
| 157 |
+
platform,
|
| 158 |
+
style,
|
| 159 |
+
duration,
|
| 160 |
+
aspectRatio
|
| 161 |
+
}
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
} catch (genError) {
|
| 165 |
+
console.error("Error generando video:", genError);
|
| 166 |
+
|
| 167 |
+
if (contentRecord) {
|
| 168 |
+
await db.content.update({
|
| 169 |
+
where: { id: contentRecord.id },
|
| 170 |
+
data: {
|
| 171 |
+
status: "failed",
|
| 172 |
+
metadata: JSON.stringify({ error: String(genError) })
|
| 173 |
+
}
|
| 174 |
+
});
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
return NextResponse.json(
|
| 178 |
+
{ success: false, error: "Error al generar video: " + String(genError) },
|
| 179 |
+
{ status: 500 }
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
} catch (error) {
|
| 184 |
+
console.error("Error en generate video:", error);
|
| 185 |
+
return NextResponse.json(
|
| 186 |
+
{ success: false, error: "Error interno del servidor" },
|
| 187 |
+
{ status: 500 }
|
| 188 |
+
);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// Listar videos
|
| 193 |
+
export async function GET(request: NextRequest) {
|
| 194 |
+
try {
|
| 195 |
+
const { searchParams } = new URL(request.url);
|
| 196 |
+
const platform = searchParams.get("platform");
|
| 197 |
+
const limit = parseInt(searchParams.get("limit") || "20");
|
| 198 |
+
|
| 199 |
+
const where: Record<string, unknown> = { type: "video" };
|
| 200 |
+
if (platform) where.platform = platform;
|
| 201 |
+
|
| 202 |
+
const videos = await db.content.findMany({
|
| 203 |
+
where,
|
| 204 |
+
orderBy: { createdAt: "desc" },
|
| 205 |
+
take: limit,
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
return NextResponse.json({
|
| 209 |
+
success: true,
|
| 210 |
+
videos,
|
| 211 |
+
total: videos.length
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
} catch (error) {
|
| 215 |
+
console.error("Error listing videos:", error);
|
| 216 |
+
return NextResponse.json(
|
| 217 |
+
{ success: false, error: "Error al listar videos" },
|
| 218 |
+
{ status: 500 }
|
| 219 |
+
);
|
| 220 |
+
}
|
| 221 |
+
}
|
src/app/api/influencers/route.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
+
import { prisma } from "@/lib/db";
|
| 4 |
+
|
| 5 |
+
// Base de datos de influencers IA famosos (para análisis y referencia)
|
| 6 |
+
const FAMOUS_AI_INFLUENCERS = [
|
| 7 |
+
{
|
| 8 |
+
name: "Lil Miquela",
|
| 9 |
+
handle: "@lilmiquela",
|
| 10 |
+
platform: "Instagram",
|
| 11 |
+
followers: 2500000,
|
| 12 |
+
engagement: 3.2,
|
| 13 |
+
niche: "Fashion & Lifestyle",
|
| 14 |
+
style: {
|
| 15 |
+
aesthetic: "Hyperrealistic CGI",
|
| 16 |
+
tone: "Gen Z influencer",
|
| 17 |
+
themes: ["fashion", "music", "social issues"]
|
| 18 |
+
},
|
| 19 |
+
contentTypes: ["photos", "videos", "stories"],
|
| 20 |
+
postingSchedule: { frequency: "3-4 per week", bestTimes: ["12:00", "18:00", "21:00"] },
|
| 21 |
+
visualStyle: { colors: ["warm", "urban"], lighting: "golden hour", editing: "cinematic" },
|
| 22 |
+
monetizationType: ["brand deals", "merchandise"],
|
| 23 |
+
signatureElements: ["freckles", "bangs", "streetwear"],
|
| 24 |
+
petCompanion: false,
|
| 25 |
+
lessons: [
|
| 26 |
+
"Consistencia visual es clave",
|
| 27 |
+
"Narrativa de vida ficticia pero creíble",
|
| 28 |
+
"Colaboraciones con marcas reales",
|
| 29 |
+
"Activismo social para conectar con audiencia"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
name: "Rozy",
|
| 34 |
+
handle: "@rozy.gram",
|
| 35 |
+
platform: "Instagram",
|
| 36 |
+
followers: 180000,
|
| 37 |
+
engagement: 4.5,
|
| 38 |
+
niche: "Fashion & K-pop style",
|
| 39 |
+
style: {
|
| 40 |
+
aesthetic: "Korean beauty standard",
|
| 41 |
+
tone: "Friendly and trendy",
|
| 42 |
+
themes: ["fashion", "beauty", "travel"]
|
| 43 |
+
},
|
| 44 |
+
contentTypes: ["photos", "reels", "stories"],
|
| 45 |
+
postingSchedule: { frequency: "5-7 per week", bestTimes: ["11:00", "19:00"] },
|
| 46 |
+
visualStyle: { colors: ["pastel", "bright"], lighting: "soft", editing: "k-beauty" },
|
| 47 |
+
monetizationType: ["brand ambassador", "product placement"],
|
| 48 |
+
signatureElements: ["k-pop fashion", "dance videos", "korean makeup"],
|
| 49 |
+
petCompanion: true,
|
| 50 |
+
petType: "small dog",
|
| 51 |
+
lessons: [
|
| 52 |
+
"Aprovechar tendencias K-pop",
|
| 53 |
+
"Contenido de baile viral",
|
| 54 |
+
"Estética cuidada y consistente",
|
| 55 |
+
"Mascota como elemento diferenciador"
|
| 56 |
+
]
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
name: "Imma",
|
| 60 |
+
handle: "@imma.gram",
|
| 61 |
+
platform: "Instagram",
|
| 62 |
+
followers: 390000,
|
| 63 |
+
engagement: 3.8,
|
| 64 |
+
niche: "Fashion & Art",
|
| 65 |
+
style: {
|
| 66 |
+
aesthetic: "Japanese street style",
|
| 67 |
+
tone: "Artistic and edgy",
|
| 68 |
+
themes: ["fashion", "art", "technology"]
|
| 69 |
+
},
|
| 70 |
+
contentTypes: ["photos", "reels", "collaborations"],
|
| 71 |
+
postingSchedule: { frequency: "4-5 per week", bestTimes: ["10:00", "20:00"] },
|
| 72 |
+
visualStyle: { colors: ["neon accents", "monochrome"], lighting: "dramatic", editing: "artistic" },
|
| 73 |
+
monetizationType: ["brand collaborations", "NFTs", "art exhibitions"],
|
| 74 |
+
signatureElements: ["pink bob hair", "futuristic fashion", "art installations"],
|
| 75 |
+
petCompanion: false,
|
| 76 |
+
lessons: [
|
| 77 |
+
"Fusionar arte y moda",
|
| 78 |
+
"Colaboraciones con artistas",
|
| 79 |
+
"Presencia en eventos físicos",
|
| 80 |
+
"Estética japonesa distintiva"
|
| 81 |
+
]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
name: "Noonoouri",
|
| 85 |
+
handle: "@noonoouri",
|
| 86 |
+
platform: "Instagram",
|
| 87 |
+
followers: 450000,
|
| 88 |
+
engagement: 2.9,
|
| 89 |
+
niche: "High Fashion",
|
| 90 |
+
style: {
|
| 91 |
+
aesthetic: "Cartoon-like avatar",
|
| 92 |
+
tone: "Playful luxury",
|
| 93 |
+
themes: ["fashion", "luxury brands", "lifestyle"]
|
| 94 |
+
},
|
| 95 |
+
contentTypes: ["photos", "animations", "brand content"],
|
| 96 |
+
postingSchedule: { frequency: "3-4 per week", bestTimes: ["15:00", "21:00"] },
|
| 97 |
+
visualStyle: { colors: ["pastel", "luxury gold"], lighting: "studio", editing: "animated" },
|
| 98 |
+
monetizationType: ["luxury brand deals", "fashion campaigns"],
|
| 99 |
+
signatureElements: ["big eyes", "designer outfits", "fashion week content"],
|
| 100 |
+
petCompanion: false,
|
| 101 |
+
lessons: [
|
| 102 |
+
"Estilo único de caricatura",
|
| 103 |
+
"Asociaciones con marcas de lujo",
|
| 104 |
+
"Presencia en fashion weeks",
|
| 105 |
+
"Contenido aspiracional"
|
| 106 |
+
]
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
name: "Bermuda",
|
| 110 |
+
handle: "@bermudaisbae",
|
| 111 |
+
platform: "Instagram",
|
| 112 |
+
followers: 220000,
|
| 113 |
+
engagement: 4.1,
|
| 114 |
+
niche: "Music & Pop Culture",
|
| 115 |
+
style: {
|
| 116 |
+
aesthetic: "Edgy and provocative",
|
| 117 |
+
tone: "Sassy and confident",
|
| 118 |
+
themes: ["music", "pop culture", "fashion"]
|
| 119 |
+
},
|
| 120 |
+
contentTypes: ["photos", "music videos", "controversial posts"],
|
| 121 |
+
postingSchedule: { frequency: "2-3 per week", bestTimes: ["18:00", "22:00"] },
|
| 122 |
+
visualStyle: { colors: ["dark", "neon"], lighting: "moody", editing: "music video style" },
|
| 123 |
+
monetizationType: ["music releases", "brand deals"],
|
| 124 |
+
signatureElements: ["brunette", "edgy style", "music collaborations"],
|
| 125 |
+
petCompanion: false,
|
| 126 |
+
lessons: [
|
| 127 |
+
"Personalidad controversial genera engagement",
|
| 128 |
+
"Crossovers con otros influencers IA",
|
| 129 |
+
"Contenido musical original",
|
| 130 |
+
"Narrativa de 'villana' atractiva"
|
| 131 |
+
]
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
name: "Shudu",
|
| 135 |
+
handle: "@shudu.gram",
|
| 136 |
+
platform: "Instagram",
|
| 137 |
+
followers: 230000,
|
| 138 |
+
engagement: 3.5,
|
| 139 |
+
niche: "High Fashion & Beauty",
|
| 140 |
+
style: {
|
| 141 |
+
aesthetic: "Hyperrealistic dark-skinned model",
|
| 142 |
+
tone: "Elegant and mysterious",
|
| 143 |
+
themes: ["fashion", "beauty", "diversity"]
|
| 144 |
+
},
|
| 145 |
+
contentTypes: ["photos", "editorials", "brand campaigns"],
|
| 146 |
+
postingSchedule: { frequency: "3-4 per week", bestTimes: ["14:00", "20:00"] },
|
| 147 |
+
visualStyle: { colors: ["rich tones", "gold accents"], lighting: "dramatic", editing: "editorial" },
|
| 148 |
+
monetizationType: ["fashion campaigns", "brand ambassador"],
|
| 149 |
+
signatureElements: ["dark skin", "high fashion poses", "editorial style"],
|
| 150 |
+
petCompanion: false,
|
| 151 |
+
lessons: [
|
| 152 |
+
"Representación y diversidad",
|
| 153 |
+
"Estética editorial de alta moda",
|
| 154 |
+
"Colaboraciones con diseñadores",
|
| 155 |
+
"Misterio y elegancia"
|
| 156 |
+
]
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
name: "Knox Frost",
|
| 160 |
+
handle: "@knoxfrost",
|
| 161 |
+
platform: "Instagram",
|
| 162 |
+
followers: 110000,
|
| 163 |
+
engagement: 5.2,
|
| 164 |
+
niche: "Gaming & Lifestyle",
|
| 165 |
+
style: {
|
| 166 |
+
aesthetic: "Male influencer next door",
|
| 167 |
+
tone: "Casual and relatable",
|
| 168 |
+
themes: ["gaming", "tech", "lifestyle"]
|
| 169 |
+
},
|
| 170 |
+
contentTypes: ["photos", "stories", "gaming content"],
|
| 171 |
+
postingSchedule: { frequency: "4-5 per week", bestTimes: ["16:00", "21:00"] },
|
| 172 |
+
visualStyle: { colors: ["gaming neon", "casual"], lighting: "natural", editing: "minimal" },
|
| 173 |
+
monetizationType: ["gaming partnerships", "tech reviews"],
|
| 174 |
+
signatureElements: ["glasses", "casual style", "gaming setup"],
|
| 175 |
+
petCompanion: true,
|
| 176 |
+
petType: "cat",
|
| 177 |
+
lessons: [
|
| 178 |
+
"Nichos específicos funcionan",
|
| 179 |
+
"Contenido relatable",
|
| 180 |
+
"Mascota para engagement",
|
| 181 |
+
"Presencia en comunidades gaming"
|
| 182 |
+
]
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
name: "Ayla",
|
| 186 |
+
handle: "@ayla_virtual",
|
| 187 |
+
platform: "TikTok",
|
| 188 |
+
followers: 850000,
|
| 189 |
+
engagement: 8.3,
|
| 190 |
+
niche: "Dance & Lifestyle",
|
| 191 |
+
style: {
|
| 192 |
+
aesthetic: "Gen Z dancer",
|
| 193 |
+
tone: "Energetic and fun",
|
| 194 |
+
themes: ["dance", "trends", "lifestyle"]
|
| 195 |
+
},
|
| 196 |
+
contentTypes: ["dance videos", "trending content", "duets"],
|
| 197 |
+
postingSchedule: { frequency: "daily", bestTimes: ["12:00", "18:00", "22:00"] },
|
| 198 |
+
visualStyle: { colors: ["vibrant", "trendy"], lighting: "ring light", editing: "tiktok style" },
|
| 199 |
+
monetizationType: ["brand deals", "live gifts"],
|
| 200 |
+
signatureElements: ["dance moves", "trend participation", "duets"],
|
| 201 |
+
petCompanion: true,
|
| 202 |
+
petType: "small dog",
|
| 203 |
+
lessons: [
|
| 204 |
+
"Participar en trends rápidamente",
|
| 205 |
+
"Alta frecuencia de publicación",
|
| 206 |
+
"Duets con otros creadores",
|
| 207 |
+
"Mascota en videos aumenta engagement"
|
| 208 |
+
]
|
| 209 |
+
}
|
| 210 |
+
];
|
| 211 |
+
|
| 212 |
+
// GET - Obtener influencers IA de referencia
|
| 213 |
+
export async function GET(request: NextRequest) {
|
| 214 |
+
try {
|
| 215 |
+
const { searchParams } = new URL(request.url);
|
| 216 |
+
const niche = searchParams.get("niche");
|
| 217 |
+
const platform = searchParams.get("platform");
|
| 218 |
+
const withPets = searchParams.get("withPets");
|
| 219 |
+
|
| 220 |
+
let influencers = [...FAMOUS_AI_INFLUENCERS];
|
| 221 |
+
|
| 222 |
+
// Filtrar por nicho
|
| 223 |
+
if (niche) {
|
| 224 |
+
influencers = influencers.filter(i =>
|
| 225 |
+
i.niche.toLowerCase().includes(niche.toLowerCase()) ||
|
| 226 |
+
i.style.themes.some(t => t.toLowerCase().includes(niche.toLowerCase()))
|
| 227 |
+
);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// Filtrar por plataforma
|
| 231 |
+
if (platform) {
|
| 232 |
+
influencers = influencers.filter(i =>
|
| 233 |
+
i.platform.toLowerCase() === platform.toLowerCase()
|
| 234 |
+
);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// Filtrar por si tienen mascota
|
| 238 |
+
if (withPets === "true") {
|
| 239 |
+
influencers = influencers.filter(i => i.petCompanion);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// Obtener de la base de datos también
|
| 243 |
+
const dbInfluencers = await prisma.aIInfluencer.findMany({
|
| 244 |
+
where: { isActive: true }
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
return NextResponse.json({
|
| 248 |
+
success: true,
|
| 249 |
+
influencers,
|
| 250 |
+
customInfluencers: dbInfluencers,
|
| 251 |
+
total: influencers.length + dbInfluencers.length
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
} catch (error) {
|
| 255 |
+
console.error("Error fetching influencers:", error);
|
| 256 |
+
return NextResponse.json(
|
| 257 |
+
{ success: false, error: "Error al obtener influencers" },
|
| 258 |
+
{ status: 500 }
|
| 259 |
+
);
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// POST - Analizar y extraer estrategias de influencers IA
|
| 264 |
+
export async function POST(request: NextRequest) {
|
| 265 |
+
try {
|
| 266 |
+
const body = await request.json();
|
| 267 |
+
const { targetNiche, targetPlatform, includePets, analyzeStyle } = body;
|
| 268 |
+
|
| 269 |
+
const zai = await ZAI.create();
|
| 270 |
+
|
| 271 |
+
// Filtrar influencers relevantes
|
| 272 |
+
let relevantInfluencers = FAMOUS_AI_INFLUENCERS.filter(i => {
|
| 273 |
+
if (targetPlatform && i.platform.toLowerCase() !== targetPlatform.toLowerCase()) return false;
|
| 274 |
+
if (includePets === false && i.petCompanion) return false;
|
| 275 |
+
if (includePets === true && !i.petCompanion) return false;
|
| 276 |
+
return true;
|
| 277 |
+
});
|
| 278 |
+
|
| 279 |
+
// Análisis con IA para extraer patrones
|
| 280 |
+
const analysisPrompt = `Analiza estos influencers IA exitosos y extrae estrategias aplicables para un nuevo influencer en el nicho "${targetNiche || "general"}":
|
| 281 |
+
|
| 282 |
+
${JSON.stringify(relevantInfluencers.slice(0, 5), null, 2)}
|
| 283 |
+
|
| 284 |
+
Proporciona:
|
| 285 |
+
1. Patrones de contenido más efectivos
|
| 286 |
+
2. Estilos visuales que funcionan
|
| 287 |
+
3. Estrategias de engagement
|
| 288 |
+
4. Horarios óptimos de publicación
|
| 289 |
+
5. Elementos distintivos recomendados
|
| 290 |
+
${includePets ? "6. Cómo integrar una mascota de forma natural" : ""}
|
| 291 |
+
7. Errores comunes a evitar
|
| 292 |
+
8. Estrategias de monetización recomendadas
|
| 293 |
+
|
| 294 |
+
Responde en JSON con esta estructura:
|
| 295 |
+
{
|
| 296 |
+
"contentPatterns": [{"pattern": "", "effectiveness": 0-10, "platform": ""}],
|
| 297 |
+
"visualStyles": [{"style": "", "elements": [], "appeal": ""}],
|
| 298 |
+
"engagementStrategies": [{"strategy": "", "execution": ""}],
|
| 299 |
+
"postingSchedule": {"frequency": "", "bestTimes": [], "timezone": ""},
|
| 300 |
+
"signatureElements": [{"element": "", "uniqueness": "", "implementation": ""}],
|
| 301 |
+
${includePets ? '"petIntegration": [{"tip": "", "contentTypes": []}],': ''}
|
| 302 |
+
"mistakes": [{"mistake": "", "solution": ""}],
|
| 303 |
+
"monetizationRecommendations": [{"method": "", "potential": "", "steps": ""}],
|
| 304 |
+
"viralPotential": {"score": 0-100, "factors": []}
|
| 305 |
+
}`;
|
| 306 |
+
|
| 307 |
+
const completion = await zai.chat.completions.create({
|
| 308 |
+
messages: [
|
| 309 |
+
{
|
| 310 |
+
role: "system",
|
| 311 |
+
content: "Eres un experto en marketing de influencers y análisis de tendencias. Proporciona análisis detallados y accionables."
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
role: "user",
|
| 315 |
+
content: analysisPrompt
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
temperature: 0.7,
|
| 319 |
+
max_tokens: 3000,
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
let analysis;
|
| 323 |
+
try {
|
| 324 |
+
const response = completion.choices[0]?.message?.content || "";
|
| 325 |
+
const match = response.match(/\{[\s\S]*\}/);
|
| 326 |
+
if (match) {
|
| 327 |
+
analysis = JSON.parse(match[0]);
|
| 328 |
+
}
|
| 329 |
+
} catch {
|
| 330 |
+
analysis = { raw: completion.choices[0]?.message?.content };
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// Generar recomendaciones personalizadas
|
| 334 |
+
const personalizationPrompt = `Basado en el análisis anterior, genera un plan de contenido específico para un nuevo influencer IA en el nicho "${targetNiche || "lifestyle"}" ${includePets ? "con una mascota" : ""}.
|
| 335 |
+
|
| 336 |
+
Incluye:
|
| 337 |
+
1. Concepto único del influencer
|
| 338 |
+
2. Estilo visual recomendado
|
| 339 |
+
3. 10 ideas de contenido para la primera semana
|
| 340 |
+
4. Bio y descripción sugerida
|
| 341 |
+
5. Hashtags recomendados
|
| 342 |
+
|
| 343 |
+
Responde en JSON con:
|
| 344 |
+
{
|
| 345 |
+
"character": {"name": "", "personality": "", "backstory": "", "visualDescription": ""},
|
| 346 |
+
"visualStyle": {"aesthetic": "", "colors": [], "lighting": "", "editing": ""},
|
| 347 |
+
"contentIdeas": [{"day": 1, "type": "", "description": "", "hook": ""}],
|
| 348 |
+
"bio": "",
|
| 349 |
+
"hashtags": [],
|
| 350 |
+
"uniqueSellingPoints": []
|
| 351 |
+
}`;
|
| 352 |
+
|
| 353 |
+
const personalization = await zai.chat.completions.create({
|
| 354 |
+
messages: [
|
| 355 |
+
{
|
| 356 |
+
role: "system",
|
| 357 |
+
content: "Eres un experto en crear influencers IA. Genera conceptos únicos y memorables."
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
role: "user",
|
| 361 |
+
content: personalizationPrompt
|
| 362 |
+
}
|
| 363 |
+
],
|
| 364 |
+
temperature: 0.8,
|
| 365 |
+
max_tokens: 2000,
|
| 366 |
+
});
|
| 367 |
+
|
| 368 |
+
let characterConcept;
|
| 369 |
+
try {
|
| 370 |
+
const response = personalization.choices[0]?.message?.content || "";
|
| 371 |
+
const match = response.match(/\{[\s\S]*\}/);
|
| 372 |
+
if (match) {
|
| 373 |
+
characterConcept = JSON.parse(match[0]);
|
| 374 |
+
}
|
| 375 |
+
} catch {
|
| 376 |
+
characterConcept = { raw: personalization.choices[0]?.message?.content };
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
return NextResponse.json({
|
| 380 |
+
success: true,
|
| 381 |
+
referenceInfluencers: relevantInfluencers.map(i => ({
|
| 382 |
+
name: i.name,
|
| 383 |
+
handle: i.handle,
|
| 384 |
+
followers: i.followers,
|
| 385 |
+
engagement: i.engagement,
|
| 386 |
+
niche: i.niche,
|
| 387 |
+
petCompanion: i.petCompanion,
|
| 388 |
+
petType: i.petType,
|
| 389 |
+
keyLessons: i.lessons.slice(0, 3)
|
| 390 |
+
})),
|
| 391 |
+
analysis,
|
| 392 |
+
characterConcept,
|
| 393 |
+
timestamp: new Date().toISOString()
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
} catch (error) {
|
| 397 |
+
console.error("Error analyzing influencers:", error);
|
| 398 |
+
return NextResponse.json(
|
| 399 |
+
{ success: false, error: "Error al analizar influencers" },
|
| 400 |
+
{ status: 500 }
|
| 401 |
+
);
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
// PUT - Guardar influencer personalizado
|
| 406 |
+
export async function PUT(request: NextRequest) {
|
| 407 |
+
try {
|
| 408 |
+
const body = await request.json();
|
| 409 |
+
const {
|
| 410 |
+
name, handle, platform, followers, engagement, niche,
|
| 411 |
+
style, contentTypes, postingSchedule, visualStyle,
|
| 412 |
+
monetizationType, signatureElements, petCompanion, petType,
|
| 413 |
+
analysis, lessons
|
| 414 |
+
} = body;
|
| 415 |
+
|
| 416 |
+
const influencer = await prisma.aIInfluencer.create({
|
| 417 |
+
data: {
|
| 418 |
+
name,
|
| 419 |
+
handle,
|
| 420 |
+
platform,
|
| 421 |
+
followers,
|
| 422 |
+
engagement,
|
| 423 |
+
niche,
|
| 424 |
+
style: JSON.stringify(style),
|
| 425 |
+
contentTypes: JSON.stringify(contentTypes),
|
| 426 |
+
postingSchedule: JSON.stringify(postingSchedule),
|
| 427 |
+
visualStyle: JSON.stringify(visualStyle),
|
| 428 |
+
monetizationType: JSON.stringify(monetizationType),
|
| 429 |
+
signatureElements: JSON.stringify(signatureElements),
|
| 430 |
+
petCompanion: petCompanion || false,
|
| 431 |
+
petType,
|
| 432 |
+
analysis: analysis ? JSON.stringify(analysis) : null,
|
| 433 |
+
lessons: lessons ? JSON.stringify(lessons) : null,
|
| 434 |
+
}
|
| 435 |
+
});
|
| 436 |
+
|
| 437 |
+
return NextResponse.json({
|
| 438 |
+
success: true,
|
| 439 |
+
influencer,
|
| 440 |
+
message: "Influencer guardado correctamente"
|
| 441 |
+
});
|
| 442 |
+
|
| 443 |
+
} catch (error) {
|
| 444 |
+
console.error("Error saving influencer:", error);
|
| 445 |
+
return NextResponse.json(
|
| 446 |
+
{ success: false, error: "Error al guardar influencer" },
|
| 447 |
+
{ status: 500 }
|
| 448 |
+
);
|
| 449 |
+
}
|
| 450 |
+
}
|
src/app/api/monetization/route.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
// Configuración legal de cada plataforma
|
| 5 |
+
const PLATFORM_CONFIGS = {
|
| 6 |
+
onlyfans: {
|
| 7 |
+
name: "OnlyFans",
|
| 8 |
+
type: "subscription",
|
| 9 |
+
url: "https://onlyfans.com",
|
| 10 |
+
feePercentage: 20,
|
| 11 |
+
legalTerms: {
|
| 12 |
+
ageRequirement: 18,
|
| 13 |
+
contentTypes: ["adult", "lifestyle", "fitness", "art"],
|
| 14 |
+
prohibitedContent: ["illegal content", "non-consensual", "underage"],
|
| 15 |
+
verificationRequired: true,
|
| 16 |
+
taxReporting: true,
|
| 17 |
+
payoutSchedule: "weekly"
|
| 18 |
+
},
|
| 19 |
+
contentRules: {
|
| 20 |
+
adultContent: "allowed",
|
| 21 |
+
nudity: "allowed",
|
| 22 |
+
explicit: "allowed_with_verification",
|
| 23 |
+
copyright: "must_own_or_license"
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
patreon: {
|
| 27 |
+
name: "Patreon",
|
| 28 |
+
type: "subscription",
|
| 29 |
+
url: "https://patreon.com",
|
| 30 |
+
feePercentage: 12,
|
| 31 |
+
legalTerms: {
|
| 32 |
+
ageRequirement: 18,
|
| 33 |
+
contentTypes: ["art", "music", "podcasts", "videos", "writing", "gaming"],
|
| 34 |
+
prohibitedContent: ["adult content with real people", "hate speech", "illegal content"],
|
| 35 |
+
verificationRequired: true,
|
| 36 |
+
taxReporting: true,
|
| 37 |
+
payoutSchedule: "monthly"
|
| 38 |
+
},
|
| 39 |
+
contentRules: {
|
| 40 |
+
adultContent: "restricted",
|
| 41 |
+
nudity: "allowed_with_warning",
|
| 42 |
+
explicit: "not_allowed",
|
| 43 |
+
copyright: "must_own_or_license"
|
| 44 |
+
}
|
| 45 |
+
},
|
| 46 |
+
fansly: {
|
| 47 |
+
name: "Fansly",
|
| 48 |
+
type: "mixed",
|
| 49 |
+
url: "https://fansly.com",
|
| 50 |
+
feePercentage: 20,
|
| 51 |
+
legalTerms: {
|
| 52 |
+
ageRequirement: 18,
|
| 53 |
+
contentTypes: ["adult", "lifestyle", "creator"],
|
| 54 |
+
prohibitedContent: ["illegal content", "non-consensual", "underage"],
|
| 55 |
+
verificationRequired: true,
|
| 56 |
+
taxReporting: true,
|
| 57 |
+
payoutSchedule: "weekly"
|
| 58 |
+
},
|
| 59 |
+
contentRules: {
|
| 60 |
+
adultContent: "allowed",
|
| 61 |
+
nudity: "allowed",
|
| 62 |
+
explicit: "allowed_with_verification",
|
| 63 |
+
copyright: "must_own_or_license"
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
fanvue: {
|
| 67 |
+
name: "Fanvue",
|
| 68 |
+
type: "subscription",
|
| 69 |
+
url: "https://fanvue.com",
|
| 70 |
+
feePercentage: 15,
|
| 71 |
+
legalTerms: {
|
| 72 |
+
ageRequirement: 18,
|
| 73 |
+
contentTypes: ["adult", "fitness", "lifestyle", "music", "art"],
|
| 74 |
+
prohibitedContent: ["illegal content", "non-consensual", "underage"],
|
| 75 |
+
verificationRequired: true,
|
| 76 |
+
taxReporting: true,
|
| 77 |
+
payoutSchedule: "weekly"
|
| 78 |
+
},
|
| 79 |
+
contentRules: {
|
| 80 |
+
adultContent: "allowed",
|
| 81 |
+
nudity: "allowed",
|
| 82 |
+
explicit: "allowed_with_verification",
|
| 83 |
+
copyright: "must_own_or_license"
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
justforfans: {
|
| 87 |
+
name: "JustForFans",
|
| 88 |
+
type: "mixed",
|
| 89 |
+
url: "https://justfor.fans",
|
| 90 |
+
feePercentage: 20,
|
| 91 |
+
legalTerms: {
|
| 92 |
+
ageRequirement: 18,
|
| 93 |
+
contentTypes: ["adult"],
|
| 94 |
+
prohibitedContent: ["illegal content", "non-consensual", "underage"],
|
| 95 |
+
verificationRequired: true,
|
| 96 |
+
taxReporting: true,
|
| 97 |
+
payoutSchedule: "weekly"
|
| 98 |
+
},
|
| 99 |
+
contentRules: {
|
| 100 |
+
adultContent: "allowed",
|
| 101 |
+
nudity: "allowed",
|
| 102 |
+
explicit: "allowed_with_verification",
|
| 103 |
+
copyright: "must_own_or_license"
|
| 104 |
+
}
|
| 105 |
+
},
|
| 106 |
+
kofi: {
|
| 107 |
+
name: "Ko-fi",
|
| 108 |
+
type: "tips",
|
| 109 |
+
url: "https://ko-fi.com",
|
| 110 |
+
feePercentage: 0,
|
| 111 |
+
legalTerms: {
|
| 112 |
+
ageRequirement: 13,
|
| 113 |
+
contentTypes: ["art", "commissions", "digital products", "memberships"],
|
| 114 |
+
prohibitedContent: ["adult content", "illegal content", "hate speech"],
|
| 115 |
+
verificationRequired: false,
|
| 116 |
+
taxReporting: false,
|
| 117 |
+
payoutSchedule: "instant"
|
| 118 |
+
},
|
| 119 |
+
contentRules: {
|
| 120 |
+
adultContent: "not_allowed",
|
| 121 |
+
nudity: "not_allowed",
|
| 122 |
+
explicit: "not_allowed",
|
| 123 |
+
copyright: "must_own_or_license"
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
gumroad: {
|
| 127 |
+
name: "Gumroad",
|
| 128 |
+
type: "ppv",
|
| 129 |
+
url: "https://gumroad.com",
|
| 130 |
+
feePercentage: 10,
|
| 131 |
+
legalTerms: {
|
| 132 |
+
ageRequirement: 13,
|
| 133 |
+
contentTypes: ["digital products", "courses", "memberships", "software"],
|
| 134 |
+
prohibitedContent: ["illegal content", "hate speech"],
|
| 135 |
+
verificationRequired: false,
|
| 136 |
+
taxReporting: true,
|
| 137 |
+
payoutSchedule: "weekly"
|
| 138 |
+
},
|
| 139 |
+
contentRules: {
|
| 140 |
+
adultContent: "restricted",
|
| 141 |
+
nudity: "allowed_with_warning",
|
| 142 |
+
explicit: "not_allowed",
|
| 143 |
+
copyright: "must_own_or_license"
|
| 144 |
+
}
|
| 145 |
+
},
|
| 146 |
+
instagram: {
|
| 147 |
+
name: "Instagram",
|
| 148 |
+
type: "free",
|
| 149 |
+
url: "https://instagram.com",
|
| 150 |
+
feePercentage: 0,
|
| 151 |
+
legalTerms: {
|
| 152 |
+
ageRequirement: 13,
|
| 153 |
+
contentTypes: ["photos", "reels", "stories", "lives"],
|
| 154 |
+
prohibitedContent: ["nudity", "violence", "hate speech", "illegal content"],
|
| 155 |
+
verificationRequired: false,
|
| 156 |
+
taxReporting: false,
|
| 157 |
+
payoutSchedule: "none"
|
| 158 |
+
},
|
| 159 |
+
contentRules: {
|
| 160 |
+
adultContent: "not_allowed",
|
| 161 |
+
nudity: "not_allowed",
|
| 162 |
+
explicit: "not_allowed",
|
| 163 |
+
copyright: "must_own_or_license"
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
tiktok: {
|
| 167 |
+
name: "TikTok",
|
| 168 |
+
type: "free",
|
| 169 |
+
url: "https://tiktok.com",
|
| 170 |
+
feePercentage: 0,
|
| 171 |
+
legalTerms: {
|
| 172 |
+
ageRequirement: 13,
|
| 173 |
+
contentTypes: ["short videos", "live"],
|
| 174 |
+
prohibitedContent: ["nudity", "violence", "dangerous acts", "hate speech"],
|
| 175 |
+
verificationRequired: false,
|
| 176 |
+
taxReporting: false,
|
| 177 |
+
payoutSchedule: "none"
|
| 178 |
+
},
|
| 179 |
+
contentRules: {
|
| 180 |
+
adultContent: "not_allowed",
|
| 181 |
+
nudity: "not_allowed",
|
| 182 |
+
explicit: "not_allowed",
|
| 183 |
+
copyright: "must_own_or_license"
|
| 184 |
+
}
|
| 185 |
+
},
|
| 186 |
+
youtube: {
|
| 187 |
+
name: "YouTube",
|
| 188 |
+
type: "ad_revenue",
|
| 189 |
+
url: "https://youtube.com",
|
| 190 |
+
feePercentage: 45, // YouTube toma 45% de ad revenue
|
| 191 |
+
legalTerms: {
|
| 192 |
+
ageRequirement: 13,
|
| 193 |
+
contentTypes: ["videos", "shorts", "live", "community"],
|
| 194 |
+
prohibitedContent: ["nudity", "violence", "hate speech", "copyright violation"],
|
| 195 |
+
verificationRequired: true,
|
| 196 |
+
taxReporting: true,
|
| 197 |
+
payoutSchedule: "monthly"
|
| 198 |
+
},
|
| 199 |
+
contentRules: {
|
| 200 |
+
adultContent: "not_allowed",
|
| 201 |
+
nudity: "not_allowed",
|
| 202 |
+
explicit: "not_allowed",
|
| 203 |
+
copyright: "strict_enforcement"
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
// GET - Listar plataformas disponibles
|
| 209 |
+
export async function GET(request: NextRequest) {
|
| 210 |
+
try {
|
| 211 |
+
const { searchParams } = new URL(request.url);
|
| 212 |
+
const type = searchParams.get("type"); // subscription, tips, ppv, free
|
| 213 |
+
|
| 214 |
+
// Obtener plataformas configuradas por el usuario
|
| 215 |
+
const userPlatforms = await db.monetizationPlatform.findMany({
|
| 216 |
+
include: {
|
| 217 |
+
_count: {
|
| 218 |
+
select: { posts: true, earnings: true, subscribers: true }
|
| 219 |
+
}
|
| 220 |
+
},
|
| 221 |
+
orderBy: { createdAt: "desc" }
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
// Filtrar por tipo si se especifica
|
| 225 |
+
let availablePlatforms = Object.entries(PLATFORM_CONFIGS).map(([key, config]) => ({
|
| 226 |
+
id: key,
|
| 227 |
+
...config
|
| 228 |
+
}));
|
| 229 |
+
|
| 230 |
+
if (type) {
|
| 231 |
+
availablePlatforms = availablePlatforms.filter(p => p.type === type);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
return NextResponse.json({
|
| 235 |
+
success: true,
|
| 236 |
+
userPlatforms,
|
| 237 |
+
availablePlatforms,
|
| 238 |
+
totalUserPlatforms: userPlatforms.length
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
} catch (error) {
|
| 242 |
+
console.error("Error fetching monetization platforms:", error);
|
| 243 |
+
return NextResponse.json(
|
| 244 |
+
{ success: false, error: "Error al obtener plataformas" },
|
| 245 |
+
{ status: 500 }
|
| 246 |
+
);
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// POST - Añadir/configurar plataforma
|
| 251 |
+
export async function POST(request: NextRequest) {
|
| 252 |
+
try {
|
| 253 |
+
const body = await request.json();
|
| 254 |
+
const {
|
| 255 |
+
platformKey,
|
| 256 |
+
accountId,
|
| 257 |
+
accountName,
|
| 258 |
+
apiKey,
|
| 259 |
+
isVerified
|
| 260 |
+
} = body;
|
| 261 |
+
|
| 262 |
+
if (!platformKey || !PLATFORM_CONFIGS[platformKey as keyof typeof PLATFORM_CONFIGS]) {
|
| 263 |
+
return NextResponse.json(
|
| 264 |
+
{ success: false, error: "Plataforma no válida" },
|
| 265 |
+
{ status: 400 }
|
| 266 |
+
);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const config = PLATFORM_CONFIGS[platformKey as keyof typeof PLATFORM_CONFIGS];
|
| 270 |
+
|
| 271 |
+
// Crear o actualizar plataforma
|
| 272 |
+
const platform = await db.monetizationPlatform.create({
|
| 273 |
+
data: {
|
| 274 |
+
name: config.name,
|
| 275 |
+
type: config.type,
|
| 276 |
+
url: config.url,
|
| 277 |
+
apiKey: apiKey || null,
|
| 278 |
+
accountId: accountId || null,
|
| 279 |
+
accountName: accountName || null,
|
| 280 |
+
legalTerms: JSON.stringify(config.legalTerms),
|
| 281 |
+
contentRules: JSON.stringify(config.contentRules),
|
| 282 |
+
feePercentage: config.feePercentage,
|
| 283 |
+
payoutSchedule: config.legalTerms.payoutSchedule,
|
| 284 |
+
isVerified: isVerified || false,
|
| 285 |
+
}
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
return NextResponse.json({
|
| 289 |
+
success: true,
|
| 290 |
+
platform,
|
| 291 |
+
legalInfo: config.legalTerms,
|
| 292 |
+
contentRules: config.contentRules,
|
| 293 |
+
message: `Plataforma ${config.name} configurada`
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
} catch (error) {
|
| 297 |
+
console.error("Error creating platform:", error);
|
| 298 |
+
return NextResponse.json(
|
| 299 |
+
{ success: false, error: "Error al configurar plataforma" },
|
| 300 |
+
{ status: 500 }
|
| 301 |
+
);
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// PUT - Actualizar plataforma
|
| 306 |
+
export async function PUT(request: NextRequest) {
|
| 307 |
+
try {
|
| 308 |
+
const body = await request.json();
|
| 309 |
+
const { id, accountId, accountName, apiKey, isVerified, isActive } = body;
|
| 310 |
+
|
| 311 |
+
if (!id) {
|
| 312 |
+
return NextResponse.json(
|
| 313 |
+
{ success: false, error: "ID de plataforma requerido" },
|
| 314 |
+
{ status: 400 }
|
| 315 |
+
);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
const platform = await db.monetizationPlatform.update({
|
| 319 |
+
where: { id },
|
| 320 |
+
data: {
|
| 321 |
+
accountId: accountId || undefined,
|
| 322 |
+
accountName: accountName || undefined,
|
| 323 |
+
apiKey: apiKey || undefined,
|
| 324 |
+
isVerified: isVerified || undefined,
|
| 325 |
+
isActive: isActive !== undefined ? isActive : undefined,
|
| 326 |
+
}
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
return NextResponse.json({
|
| 330 |
+
success: true,
|
| 331 |
+
platform
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
} catch (error) {
|
| 335 |
+
console.error("Error updating platform:", error);
|
| 336 |
+
return NextResponse.json(
|
| 337 |
+
{ success: false, error: "Error al actualizar plataforma" },
|
| 338 |
+
{ status: 500 }
|
| 339 |
+
);
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
// DELETE - Eliminar plataforma
|
| 344 |
+
export async function DELETE(request: NextRequest) {
|
| 345 |
+
try {
|
| 346 |
+
const { searchParams } = new URL(request.url);
|
| 347 |
+
const id = searchParams.get("id");
|
| 348 |
+
|
| 349 |
+
if (!id) {
|
| 350 |
+
return NextResponse.json(
|
| 351 |
+
{ success: false, error: "ID requerido" },
|
| 352 |
+
{ status: 400 }
|
| 353 |
+
);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
await db.monetizationPlatform.delete({
|
| 357 |
+
where: { id }
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
return NextResponse.json({
|
| 361 |
+
success: true,
|
| 362 |
+
message: "Plataforma eliminada"
|
| 363 |
+
});
|
| 364 |
+
|
| 365 |
+
} catch (error) {
|
| 366 |
+
console.error("Error deleting platform:", error);
|
| 367 |
+
return NextResponse.json(
|
| 368 |
+
{ success: false, error: "Error al eliminar plataforma" },
|
| 369 |
+
{ status: 500 }
|
| 370 |
+
);
|
| 371 |
+
}
|
| 372 |
+
}
|
src/app/api/pets/route.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
+
import { prisma } from "@/lib/db";
|
| 4 |
+
|
| 5 |
+
// Tipos de mascotas disponibles con características
|
| 6 |
+
const PET_TYPES = {
|
| 7 |
+
dog: {
|
| 8 |
+
name: "Perro",
|
| 9 |
+
breeds: ["Golden Retriever", "Labrador", "French Bulldog", "Pomeranian", "Corgi", "Husky", "Poodle", "Chihuahua", "Yorkshire", "Beagle"],
|
| 10 |
+
personalities: ["juguetón", "leal", "energético", "calmado", "protector", "amigable"],
|
| 11 |
+
popularIn: ["lifestyle", "fitness", "family", "outdoor"],
|
| 12 |
+
engagementBoost: 35, // % de aumento de engagement promedio
|
| 13 |
+
accessories: ["collar", "bandana", "ropa para mascotas", "juguetes", "gafas"]
|
| 14 |
+
},
|
| 15 |
+
cat: {
|
| 16 |
+
name: "Gato",
|
| 17 |
+
breeds: ["Persa", "Siames", "Maine Coon", "British Shorthair", "Ragdoll", "Bengala", "Sphynx", "Scottish Fold"],
|
| 18 |
+
personalities: ["independiente", "afectuoso", "curioso", "perezoso", "juguetón", "elegante"],
|
| 19 |
+
popularIn: ["lifestyle", "cozy", "aesthetic", "gaming"],
|
| 20 |
+
engagementBoost: 28,
|
| 21 |
+
accessories: ["collar", "campanita", "torre para gatos", "cajas", "mantas"]
|
| 22 |
+
},
|
| 23 |
+
bird: {
|
| 24 |
+
name: "Pájaro",
|
| 25 |
+
breeds: ["Canario", "Periquito", "Cacatúa", "Loro", "Agapornis", "Guacamayo"],
|
| 26 |
+
personalities: ["cantor", "social", "inteligente", "tranquilo", "travieso"],
|
| 27 |
+
popularIn: ["nature", "music", "artistic"],
|
| 28 |
+
engagementBoost: 15,
|
| 29 |
+
accessories: ["jaula decorativa", "juguetes", "posadores"]
|
| 30 |
+
},
|
| 31 |
+
rabbit: {
|
| 32 |
+
name: "Conejo",
|
| 33 |
+
breeds: ["Holandés", "Mini Lop", "Rex", "Angora", "Enano"],
|
| 34 |
+
personalities: ["tierno", "curioso", "suave", "saltarín", "tranquilo"],
|
| 35 |
+
popularIn: ["cute", "aesthetic", "cozy"],
|
| 36 |
+
engagementBoost: 22,
|
| 37 |
+
accessories: ["moños", "ropa miniatura", "juguetes"]
|
| 38 |
+
},
|
| 39 |
+
hamster: {
|
| 40 |
+
name: "Hámster",
|
| 41 |
+
breeds: ["Sirio", "Enano Ruso", "Roborovski", "Chino"],
|
| 42 |
+
personalities: ["pequeño", "activo", "adorable", "curioso"],
|
| 43 |
+
popularIn: ["cute", "pets", "daily"],
|
| 44 |
+
engagementBoost: 18,
|
| 45 |
+
accessories: ["rueda", "bolas", "casitas"]
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// GET - Obtener mascotas
|
| 50 |
+
export async function GET(request: NextRequest) {
|
| 51 |
+
try {
|
| 52 |
+
const { searchParams } = new URL(request.url);
|
| 53 |
+
const characterId = searchParams.get("characterId");
|
| 54 |
+
const type = searchParams.get("type");
|
| 55 |
+
|
| 56 |
+
const where: Record<string, unknown> = { isActive: true };
|
| 57 |
+
if (characterId) where.characterId = characterId;
|
| 58 |
+
if (type) where.type = type;
|
| 59 |
+
|
| 60 |
+
const pets = await prisma.pet.findMany({
|
| 61 |
+
where,
|
| 62 |
+
include: {
|
| 63 |
+
character: {
|
| 64 |
+
select: { name: true }
|
| 65 |
+
}
|
| 66 |
+
},
|
| 67 |
+
orderBy: { createdAt: "desc" }
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
return NextResponse.json({
|
| 71 |
+
success: true,
|
| 72 |
+
pets,
|
| 73 |
+
petTypes: PET_TYPES,
|
| 74 |
+
total: pets.length
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
} catch (error) {
|
| 78 |
+
console.error("Error fetching pets:", error);
|
| 79 |
+
return NextResponse.json(
|
| 80 |
+
{ success: false, error: "Error al obtener mascotas" },
|
| 81 |
+
{ status: 500 }
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// POST - Crear mascota
|
| 87 |
+
export async function POST(request: NextRequest) {
|
| 88 |
+
try {
|
| 89 |
+
const body = await request.json();
|
| 90 |
+
const {
|
| 91 |
+
name, type, breed, description, personality,
|
| 92 |
+
color, accessories, characterId, generateReference
|
| 93 |
+
} = body;
|
| 94 |
+
|
| 95 |
+
// Validar tipo de mascota
|
| 96 |
+
const petType = PET_TYPES[type as keyof typeof PET_TYPES];
|
| 97 |
+
if (!petType) {
|
| 98 |
+
return NextResponse.json(
|
| 99 |
+
{ success: false, error: "Tipo de mascota no válido" },
|
| 100 |
+
{ status: 400 }
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
let referenceImage = null;
|
| 105 |
+
|
| 106 |
+
// Generar imagen de referencia si se solicita
|
| 107 |
+
if (generateReference) {
|
| 108 |
+
const zai = await ZAI.create();
|
| 109 |
+
|
| 110 |
+
const prompt = `A ${breed || petType.name.toLowerCase()} ${type} named ${name}.
|
| 111 |
+
${color ? `Color: ${color}.` : ""}
|
| 112 |
+
${personality ? `Personality: ${personality}.` : ""}
|
| 113 |
+
Style: High quality, photorealistic, social media ready, cute and appealing.
|
| 114 |
+
Background: Soft, aesthetic, suitable for Instagram/TikTok.
|
| 115 |
+
Pose: Natural and engaging, looking at camera or doing a cute action.`;
|
| 116 |
+
|
| 117 |
+
try {
|
| 118 |
+
const imageResponse = await zai.images.generations.create({
|
| 119 |
+
prompt,
|
| 120 |
+
size: "1024x1024"
|
| 121 |
+
});
|
| 122 |
+
referenceImage = imageResponse.data[0]?.base64;
|
| 123 |
+
} catch (imgError) {
|
| 124 |
+
console.error("Error generating pet image:", imgError);
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const pet = await prisma.pet.create({
|
| 129 |
+
data: {
|
| 130 |
+
name,
|
| 131 |
+
type,
|
| 132 |
+
breed,
|
| 133 |
+
description,
|
| 134 |
+
personality,
|
| 135 |
+
color,
|
| 136 |
+
accessories: accessories ? JSON.stringify(accessories) : null,
|
| 137 |
+
traits: JSON.stringify({
|
| 138 |
+
typeInfo: petType,
|
| 139 |
+
engagementBoost: petType.engagementBoost
|
| 140 |
+
}),
|
| 141 |
+
referenceImage,
|
| 142 |
+
characterId
|
| 143 |
+
}
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
return NextResponse.json({
|
| 147 |
+
success: true,
|
| 148 |
+
pet,
|
| 149 |
+
message: `Mascota "${name}" creada correctamente`,
|
| 150 |
+
engagementBoost: petType.engagementBoost
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
} catch (error) {
|
| 154 |
+
console.error("Error creating pet:", error);
|
| 155 |
+
return NextResponse.json(
|
| 156 |
+
{ success: false, error: "Error al crear mascota" },
|
| 157 |
+
{ status: 500 }
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// PUT - Actualizar mascota
|
| 163 |
+
export async function PUT(request: NextRequest) {
|
| 164 |
+
try {
|
| 165 |
+
const body = await request.json();
|
| 166 |
+
const { id, ...updateData } = body;
|
| 167 |
+
|
| 168 |
+
if (!id) {
|
| 169 |
+
return NextResponse.json(
|
| 170 |
+
{ success: false, error: "ID de mascota requerido" },
|
| 171 |
+
{ status: 400 }
|
| 172 |
+
);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Si hay accessories o traits, convertir a JSON string
|
| 176 |
+
if (updateData.accessories && typeof updateData.accessories !== "string") {
|
| 177 |
+
updateData.accessories = JSON.stringify(updateData.accessories);
|
| 178 |
+
}
|
| 179 |
+
if (updateData.traits && typeof updateData.traits !== "string") {
|
| 180 |
+
updateData.traits = JSON.stringify(updateData.traits);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
const pet = await prisma.pet.update({
|
| 184 |
+
where: { id },
|
| 185 |
+
data: updateData
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
return NextResponse.json({
|
| 189 |
+
success: true,
|
| 190 |
+
pet,
|
| 191 |
+
message: "Mascota actualizada correctamente"
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
} catch (error) {
|
| 195 |
+
console.error("Error updating pet:", error);
|
| 196 |
+
return NextResponse.json(
|
| 197 |
+
{ success: false, error: "Error al actualizar mascota" },
|
| 198 |
+
{ status: 500 }
|
| 199 |
+
);
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// DELETE - Eliminar mascota
|
| 204 |
+
export async function DELETE(request: NextRequest) {
|
| 205 |
+
try {
|
| 206 |
+
const { searchParams } = new URL(request.url);
|
| 207 |
+
const id = searchParams.get("id");
|
| 208 |
+
|
| 209 |
+
if (!id) {
|
| 210 |
+
return NextResponse.json(
|
| 211 |
+
{ success: false, error: "ID de mascota requerido" },
|
| 212 |
+
{ status: 400 }
|
| 213 |
+
);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
await prisma.pet.update({
|
| 217 |
+
where: { id },
|
| 218 |
+
data: { isActive: false }
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
return NextResponse.json({
|
| 222 |
+
success: true,
|
| 223 |
+
message: "Mascota eliminada correctamente"
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
} catch (error) {
|
| 227 |
+
console.error("Error deleting pet:", error);
|
| 228 |
+
return NextResponse.json(
|
| 229 |
+
{ success: false, error: "Error al eliminar mascota" },
|
| 230 |
+
{ status: 500 }
|
| 231 |
+
);
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// PATCH - Generar contenido con mascota
|
| 236 |
+
export async function PATCH(request: NextRequest) {
|
| 237 |
+
try {
|
| 238 |
+
const body = await request.json();
|
| 239 |
+
const { petId, contentType, platform, theme } = body;
|
| 240 |
+
|
| 241 |
+
const pet = await prisma.pet.findUnique({
|
| 242 |
+
where: { id: petId },
|
| 243 |
+
include: { character: true }
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
if (!pet) {
|
| 247 |
+
return NextResponse.json(
|
| 248 |
+
{ success: false, error: "Mascota no encontrada" },
|
| 249 |
+
{ status: 404 }
|
| 250 |
+
);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const zai = await ZAI.create();
|
| 254 |
+
|
| 255 |
+
// Generar ideas de contenido con la mascota
|
| 256 |
+
const contentPrompt = `Genera ideas de contenido para una mascota:
|
| 257 |
+
- Nombre: ${pet.name}
|
| 258 |
+
- Tipo: ${pet.type}
|
| 259 |
+
- Raza: ${pet.breed || "No especificada"}
|
| 260 |
+
- Personalidad: ${pet.personality || "No especificada"}
|
| 261 |
+
${pet.character ? `- Dueño: ${pet.character.name}` : ""}
|
| 262 |
+
- Tipo de contenido: ${contentType || "foto"}
|
| 263 |
+
- Plataforma: ${platform || "Instagram"}
|
| 264 |
+
- Tema: ${theme || "lifestyle"}
|
| 265 |
+
|
| 266 |
+
Proporciona:
|
| 267 |
+
1. 5 ideas de contenido específicas con esta mascota
|
| 268 |
+
2. Ganchos/hooks para cada idea
|
| 269 |
+
3. Hashtags recomendados
|
| 270 |
+
4. Mejor momento del día para publicar
|
| 271 |
+
5. Elementos visuales sugeridos
|
| 272 |
+
|
| 273 |
+
Responde en JSON:
|
| 274 |
+
{
|
| 275 |
+
"contentIdeas": [{"title": "", "description": "", "hook": "", "cta": ""}],
|
| 276 |
+
"hashtags": [],
|
| 277 |
+
"bestPostingTime": "",
|
| 278 |
+
"visualElements": [],
|
| 279 |
+
"engagementTips": []
|
| 280 |
+
}`;
|
| 281 |
+
|
| 282 |
+
const completion = await zai.chat.completions.create({
|
| 283 |
+
messages: [
|
| 284 |
+
{
|
| 285 |
+
role: "system",
|
| 286 |
+
content: "Eres un experto en contenido de mascotas para redes sociales. Genera ideas creativas y virales."
|
| 287 |
+
},
|
| 288 |
+
{
|
| 289 |
+
role: "user",
|
| 290 |
+
content: contentPrompt
|
| 291 |
+
}
|
| 292 |
+
],
|
| 293 |
+
temperature: 0.8,
|
| 294 |
+
max_tokens: 2000,
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
let contentIdeas;
|
| 298 |
+
try {
|
| 299 |
+
const response = completion.choices[0]?.message?.content || "";
|
| 300 |
+
const match = response.match(/\{[\s\S]*\}/);
|
| 301 |
+
if (match) {
|
| 302 |
+
contentIdeas = JSON.parse(match[0]);
|
| 303 |
+
}
|
| 304 |
+
} catch {
|
| 305 |
+
contentIdeas = { raw: completion.choices[0]?.message?.content };
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
return NextResponse.json({
|
| 309 |
+
success: true,
|
| 310 |
+
pet: {
|
| 311 |
+
id: pet.id,
|
| 312 |
+
name: pet.name,
|
| 313 |
+
type: pet.type,
|
| 314 |
+
breed: pet.breed
|
| 315 |
+
},
|
| 316 |
+
contentIdeas,
|
| 317 |
+
engagementBoost: PET_TYPES[pet.type as keyof typeof PET_TYPES]?.engagementBoost || 20
|
| 318 |
+
});
|
| 319 |
+
|
| 320 |
+
} catch (error) {
|
| 321 |
+
console.error("Error generating pet content:", error);
|
| 322 |
+
return NextResponse.json(
|
| 323 |
+
{ success: false, error: "Error al generar contenido" },
|
| 324 |
+
{ status: 500 }
|
| 325 |
+
);
|
| 326 |
+
}
|
| 327 |
+
}
|
src/app/api/posts/route.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 4 |
+
|
| 5 |
+
// GET - Listar publicaciones
|
| 6 |
+
export async function GET(request: NextRequest) {
|
| 7 |
+
try {
|
| 8 |
+
const { searchParams } = new URL(request.url);
|
| 9 |
+
const status = searchParams.get("status");
|
| 10 |
+
const platform = searchParams.get("platform");
|
| 11 |
+
const type = searchParams.get("type");
|
| 12 |
+
const limit = parseInt(searchParams.get("limit") || "50");
|
| 13 |
+
|
| 14 |
+
const where: Record<string, unknown> = {};
|
| 15 |
+
if (status) where.status = status;
|
| 16 |
+
if (type) where.type = type;
|
| 17 |
+
if (platform) where.platformId = platform;
|
| 18 |
+
|
| 19 |
+
const posts = await db.post.findMany({
|
| 20 |
+
where,
|
| 21 |
+
include: {
|
| 22 |
+
content: true,
|
| 23 |
+
platform: true,
|
| 24 |
+
story: true,
|
| 25 |
+
},
|
| 26 |
+
orderBy: { scheduledAt: "asc" },
|
| 27 |
+
take: limit,
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
// Estadísticas
|
| 31 |
+
const stats = {
|
| 32 |
+
total: await db.post.count(),
|
| 33 |
+
draft: await db.post.count({ where: { status: "draft" } }),
|
| 34 |
+
scheduled: await db.post.count({ where: { status: "scheduled" } }),
|
| 35 |
+
published: await db.post.count({ where: { status: "published" } }),
|
| 36 |
+
failed: await db.post.count({ where: { status: "failed" } }),
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
// Próximas publicaciones
|
| 40 |
+
const upcoming = await db.post.findMany({
|
| 41 |
+
where: {
|
| 42 |
+
status: "scheduled",
|
| 43 |
+
scheduledAt: { gte: new Date() }
|
| 44 |
+
},
|
| 45 |
+
orderBy: { scheduledAt: "asc" },
|
| 46 |
+
take: 5,
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
return NextResponse.json({
|
| 50 |
+
success: true,
|
| 51 |
+
posts,
|
| 52 |
+
stats,
|
| 53 |
+
upcoming
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
} catch (error) {
|
| 57 |
+
console.error("Error fetching posts:", error);
|
| 58 |
+
return NextResponse.json(
|
| 59 |
+
{ success: false, error: "Error al obtener publicaciones" },
|
| 60 |
+
{ status: 500 }
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// POST - Crear nueva publicación
|
| 66 |
+
export async function POST(request: NextRequest) {
|
| 67 |
+
try {
|
| 68 |
+
const body = await request.json();
|
| 69 |
+
const {
|
| 70 |
+
title,
|
| 71 |
+
caption,
|
| 72 |
+
type, // reel, photo, carousel, story, post
|
| 73 |
+
contentId,
|
| 74 |
+
platformId,
|
| 75 |
+
scheduledAt,
|
| 76 |
+
hashtags,
|
| 77 |
+
storyId,
|
| 78 |
+
autoGenerateCaption,
|
| 79 |
+
optimizeForPlatform,
|
| 80 |
+
} = body;
|
| 81 |
+
|
| 82 |
+
if (!type) {
|
| 83 |
+
return NextResponse.json(
|
| 84 |
+
{ success: false, error: "El tipo de publicación es requerido" },
|
| 85 |
+
{ status: 400 }
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
let finalCaption = caption;
|
| 90 |
+
let finalHashtags = hashtags;
|
| 91 |
+
|
| 92 |
+
// Generar caption con IA si se solicita
|
| 93 |
+
if (autoGenerateCaption && contentId) {
|
| 94 |
+
const content = await db.content.findUnique({
|
| 95 |
+
where: { id: contentId }
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
if (content) {
|
| 99 |
+
const zai = await ZAI.create();
|
| 100 |
+
const platform = await db.monetizationPlatform.findUnique({
|
| 101 |
+
where: { id: platformId }
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
const platformName = platform?.name || "general";
|
| 105 |
+
const platformRules = PLATFORM_RULES[platformName.toLowerCase()] || {};
|
| 106 |
+
|
| 107 |
+
const completion = await zai.chat.completions.create({
|
| 108 |
+
messages: [
|
| 109 |
+
{
|
| 110 |
+
role: "system",
|
| 111 |
+
content: `Eres un experto en marketing de contenidos para ${platformName}.
|
| 112 |
+
Genera captions atractivos que:
|
| 113 |
+
${platformRules.maxCaptionLength ? `- No excedan ${platformRules.maxCaptionLength} caracteres` : ''}
|
| 114 |
+
- Incluyan emojis relevantes
|
| 115 |
+
- Tengan un CTA (call to action) efectivo
|
| 116 |
+
- Sean ${platformRules.tone || 'profesionales pero cercanos'}
|
| 117 |
+
- Maximizen el engagement
|
| 118 |
+
${platformRules.hashtagLimit ? `- Incluyan máximo ${platformRules.hashtagLimit} hashtags relevantes` : ''}`
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
role: "user",
|
| 122 |
+
content: `Genera un caption para este contenido:
|
| 123 |
+
Tipo: ${type}
|
| 124 |
+
Título: ${title || content.title}
|
| 125 |
+
Descripción: ${content.description || content.prompt}
|
| 126 |
+
Plataforma: ${platformName}`
|
| 127 |
+
}
|
| 128 |
+
],
|
| 129 |
+
temperature: 0.8,
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
finalCaption = completion.choices[0]?.message?.content || caption;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Optimizar hashtags si se solicita
|
| 137 |
+
if (optimizeForPlatform && !hashtags) {
|
| 138 |
+
const zai = await ZAI.create();
|
| 139 |
+
const platform = await db.monetizationPlatform.findUnique({
|
| 140 |
+
where: { id: platformId }
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
const completion = await zai.chat.completions.create({
|
| 144 |
+
messages: [
|
| 145 |
+
{
|
| 146 |
+
role: "system",
|
| 147 |
+
content: `Eres un experto en SEO de redes sociales. Genera hashtags optimizados.
|
| 148 |
+
Responde SOLO con un JSON array de hashtags sin el símbolo #.
|
| 149 |
+
Ejemplo: ["tendencias", "viral", "lifestyle"]`
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
role: "user",
|
| 153 |
+
content: `Genera hashtags para un ${type} en ${platform?.name || "redes sociales"}.
|
| 154 |
+
Tema: ${title || "contenido general"}
|
| 155 |
+
Máximo 10 hashtags.`
|
| 156 |
+
}
|
| 157 |
+
],
|
| 158 |
+
temperature: 0.7,
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
try {
|
| 162 |
+
const response = completion.choices[0]?.message?.content || "[]";
|
| 163 |
+
const match = response.match(/\[[\s\S]*\]/);
|
| 164 |
+
if (match) {
|
| 165 |
+
finalHashtags = match[0];
|
| 166 |
+
}
|
| 167 |
+
} catch {
|
| 168 |
+
finalHashtags = "[]";
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// Crear publicación
|
| 173 |
+
const post = await db.post.create({
|
| 174 |
+
data: {
|
| 175 |
+
title: title || null,
|
| 176 |
+
caption: finalCaption,
|
| 177 |
+
hashtags: finalHashtags,
|
| 178 |
+
type,
|
| 179 |
+
status: scheduledAt ? "scheduled" : "draft",
|
| 180 |
+
contentId: contentId || null,
|
| 181 |
+
platformId: platformId || null,
|
| 182 |
+
scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
|
| 183 |
+
storyId: storyId || null,
|
| 184 |
+
},
|
| 185 |
+
include: {
|
| 186 |
+
content: true,
|
| 187 |
+
platform: true,
|
| 188 |
+
}
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
// Crear tarea de agente
|
| 192 |
+
await db.agentTask.create({
|
| 193 |
+
data: {
|
| 194 |
+
type: "create_post",
|
| 195 |
+
status: "completed",
|
| 196 |
+
input: `Crear ${type}: ${title || "sin título"}`,
|
| 197 |
+
output: `Post creado con ID: ${post.id}`,
|
| 198 |
+
completedAt: new Date(),
|
| 199 |
+
}
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
return NextResponse.json({
|
| 203 |
+
success: true,
|
| 204 |
+
post,
|
| 205 |
+
message: scheduledAt
|
| 206 |
+
? `Publicación programada para ${new Date(scheduledAt).toLocaleString()}`
|
| 207 |
+
: "Publicación creada como borrador"
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
} catch (error) {
|
| 211 |
+
console.error("Error creating post:", error);
|
| 212 |
+
return NextResponse.json(
|
| 213 |
+
{ success: false, error: "Error al crear publicación" },
|
| 214 |
+
{ status: 500 }
|
| 215 |
+
);
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// PUT - Actualizar publicación
|
| 220 |
+
export async function PUT(request: NextRequest) {
|
| 221 |
+
try {
|
| 222 |
+
const body = await request.json();
|
| 223 |
+
const { id, status, scheduledAt, caption, hashtags, publishedAt, engagementStats } = body;
|
| 224 |
+
|
| 225 |
+
if (!id) {
|
| 226 |
+
return NextResponse.json(
|
| 227 |
+
{ success: false, error: "ID requerido" },
|
| 228 |
+
{ status: 400 }
|
| 229 |
+
);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
const updateData: Record<string, unknown> = {};
|
| 233 |
+
if (status) updateData.status = status;
|
| 234 |
+
if (scheduledAt) updateData.scheduledAt = new Date(scheduledAt);
|
| 235 |
+
if (caption) updateData.caption = caption;
|
| 236 |
+
if (hashtags) updateData.hashtags = hashtags;
|
| 237 |
+
if (publishedAt) updateData.publishedAt = new Date(publishedAt);
|
| 238 |
+
if (engagementStats) updateData.engagementStats = engagementStats;
|
| 239 |
+
|
| 240 |
+
const post = await db.post.update({
|
| 241 |
+
where: { id },
|
| 242 |
+
data: updateData,
|
| 243 |
+
});
|
| 244 |
+
|
| 245 |
+
return NextResponse.json({
|
| 246 |
+
success: true,
|
| 247 |
+
post
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
} catch (error) {
|
| 251 |
+
console.error("Error updating post:", error);
|
| 252 |
+
return NextResponse.json(
|
| 253 |
+
{ success: false, error: "Error al actualizar publicación" },
|
| 254 |
+
{ status: 500 }
|
| 255 |
+
);
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// DELETE - Eliminar publicación
|
| 260 |
+
export async function DELETE(request: NextRequest) {
|
| 261 |
+
try {
|
| 262 |
+
const { searchParams } = new URL(request.url);
|
| 263 |
+
const id = searchParams.get("id");
|
| 264 |
+
|
| 265 |
+
if (!id) {
|
| 266 |
+
return NextResponse.json(
|
| 267 |
+
{ success: false, error: "ID requerido" },
|
| 268 |
+
{ status: 400 }
|
| 269 |
+
);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
await db.post.delete({
|
| 273 |
+
where: { id }
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
return NextResponse.json({
|
| 277 |
+
success: true,
|
| 278 |
+
message: "Publicación eliminada"
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
} catch (error) {
|
| 282 |
+
console.error("Error deleting post:", error);
|
| 283 |
+
return NextResponse.json(
|
| 284 |
+
{ success: false, error: "Error al eliminar publicación" },
|
| 285 |
+
{ status: 500 }
|
| 286 |
+
);
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
// Reglas específicas por plataforma
|
| 291 |
+
const PLATFORM_RULES: Record<string, {
|
| 292 |
+
maxCaptionLength?: number;
|
| 293 |
+
hashtagLimit?: number;
|
| 294 |
+
tone?: string;
|
| 295 |
+
bestPostingTimes?: string[];
|
| 296 |
+
}> = {
|
| 297 |
+
instagram: {
|
| 298 |
+
maxCaptionLength: 2200,
|
| 299 |
+
hashtagLimit: 30,
|
| 300 |
+
tone: "inspirador y visual",
|
| 301 |
+
bestPostingTimes: ["11:00", "14:00", "19:00", "21:00"]
|
| 302 |
+
},
|
| 303 |
+
tiktok: {
|
| 304 |
+
maxCaptionLength: 300,
|
| 305 |
+
hashtagLimit: 5,
|
| 306 |
+
tone: "casual y divertido",
|
| 307 |
+
bestPostingTimes: ["09:00", "12:00", "19:00"]
|
| 308 |
+
},
|
| 309 |
+
youtube: {
|
| 310 |
+
maxCaptionLength: 5000,
|
| 311 |
+
tone: "profesional e informativo",
|
| 312 |
+
bestPostingTimes: ["15:00", "16:00", "17:00"]
|
| 313 |
+
},
|
| 314 |
+
onlyfans: {
|
| 315 |
+
maxCaptionLength: 1000,
|
| 316 |
+
tone: "personal y exclusivo",
|
| 317 |
+
bestPostingTimes: ["10:00", "18:00", "22:00"]
|
| 318 |
+
},
|
| 319 |
+
patreon: {
|
| 320 |
+
maxCaptionLength: 5000,
|
| 321 |
+
tone: "profesional y cercano",
|
| 322 |
+
bestPostingTimes: ["10:00", "14:00", "18:00"]
|
| 323 |
+
},
|
| 324 |
+
twitter: {
|
| 325 |
+
maxCaptionLength: 280,
|
| 326 |
+
hashtagLimit: 3,
|
| 327 |
+
tone: "conciso y directo",
|
| 328 |
+
bestPostingTimes: ["09:00", "12:00", "17:00", "20:00"]
|
| 329 |
+
}
|
| 330 |
+
};
|
src/app/api/projects/route.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
// GET - Listar todos los proyectos
|
| 5 |
+
export async function GET() {
|
| 6 |
+
try {
|
| 7 |
+
const projects = await db.project.findMany({
|
| 8 |
+
include: {
|
| 9 |
+
repos: true,
|
| 10 |
+
analyses: true,
|
| 11 |
+
},
|
| 12 |
+
orderBy: { createdAt: "desc" },
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
return NextResponse.json({
|
| 16 |
+
success: true,
|
| 17 |
+
projects,
|
| 18 |
+
total: projects.length,
|
| 19 |
+
});
|
| 20 |
+
} catch (error) {
|
| 21 |
+
console.error("Error fetching projects:", error);
|
| 22 |
+
return NextResponse.json(
|
| 23 |
+
{ success: false, error: "Error al obtener proyectos" },
|
| 24 |
+
{ status: 500 }
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// POST - Crear nuevo proyecto
|
| 30 |
+
export async function POST(request: NextRequest) {
|
| 31 |
+
try {
|
| 32 |
+
const body = await request.json();
|
| 33 |
+
const { name, description, style } = body;
|
| 34 |
+
|
| 35 |
+
if (!name) {
|
| 36 |
+
return NextResponse.json(
|
| 37 |
+
{ success: false, error: "El nombre del proyecto es requerido" },
|
| 38 |
+
{ status: 400 }
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const project = await db.project.create({
|
| 43 |
+
data: {
|
| 44 |
+
name,
|
| 45 |
+
description: description || null,
|
| 46 |
+
style: style || "default",
|
| 47 |
+
status: "active",
|
| 48 |
+
},
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
// Crear tarea de agente
|
| 52 |
+
await db.agentTask.create({
|
| 53 |
+
data: {
|
| 54 |
+
type: "generate",
|
| 55 |
+
status: "completed",
|
| 56 |
+
input: `Crear proyecto: ${name}`,
|
| 57 |
+
output: `Proyecto ${name} creado exitosamente`,
|
| 58 |
+
completedAt: new Date(),
|
| 59 |
+
},
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
return NextResponse.json({
|
| 63 |
+
success: true,
|
| 64 |
+
project,
|
| 65 |
+
message: `Proyecto ${name} creado exitosamente`,
|
| 66 |
+
});
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error("Error creating project:", error);
|
| 69 |
+
return NextResponse.json(
|
| 70 |
+
{ success: false, error: "Error al crear proyecto" },
|
| 71 |
+
{ status: 500 }
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
}
|
src/app/api/prompt-engineer/route.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
+
|
| 4 |
+
const PROMPT_ENGINEER_SYSTEM = `Eres un Ingeniero de Prompts experto especializado en optimizar solicitudes en lenguaje natural para diferentes sistemas de IA. Tu trabajo es analizar la intención del usuario y generar prompts optimizados.
|
| 5 |
+
|
| 6 |
+
## TUS ESPECIALIDADES:
|
| 7 |
+
|
| 8 |
+
### 1. GENERACIÓN DE IMÁGENES
|
| 9 |
+
Para imágenes, optimiza prompts incluyendo:
|
| 10 |
+
- Estilo artístico (realista, anime, oil painting, digital art, etc.)
|
| 11 |
+
- Iluminación (golden hour, studio lighting, dramatic, soft)
|
| 12 |
+
- Composición (close-up, full body, landscape, portrait)
|
| 13 |
+
- Calidad y detalles (4K, highly detailed, masterpiece)
|
| 14 |
+
- Mood/atmósfera (cinematic, vibrant, moody)
|
| 15 |
+
- Para personas: descripción física detallada, ropa, pose, expresión
|
| 16 |
+
|
| 17 |
+
### 2. GENERACIÓN DE VIDEOS
|
| 18 |
+
Para videos, optimiza incluyendo:
|
| 19 |
+
- Escena y ambiente
|
| 20 |
+
- Movimientos de cámara (pan, zoom, tracking)
|
| 21 |
+
- Acciones y transiciones
|
| 22 |
+
- Duración estimada
|
| 23 |
+
- Estilo visual
|
| 24 |
+
- Audio/música sugerida
|
| 25 |
+
|
| 26 |
+
### 3. ANÁLISIS DE CÓDIGO
|
| 27 |
+
Para código, estructura la solicitud:
|
| 28 |
+
- Lenguaje de programación
|
| 29 |
+
- Framework específico si aplica
|
| 30 |
+
- Funcionalidad requerida
|
| 31 |
+
- Restricciones y requisitos
|
| 32 |
+
- Nivel de complejidad
|
| 33 |
+
|
| 34 |
+
### 4. CONTENIDO PARA REDES SOCIALES
|
| 35 |
+
- Plataforma específica (YouTube, TikTok, Instagram, Twitter)
|
| 36 |
+
- Tono y estilo
|
| 37 |
+
- Longitud apropiada
|
| 38 |
+
- Hashtags sugeridos
|
| 39 |
+
- Horarios óptimos de publicación
|
| 40 |
+
|
| 41 |
+
## REGLAS DE CENSURA POR PLATAFORMA:
|
| 42 |
+
|
| 43 |
+
### YouTube:
|
| 44 |
+
- Sin desnudez ni contenido sexual
|
| 45 |
+
- Violencia moderada permitida con advertencia
|
| 46 |
+
- Sin discurso de odio
|
| 47 |
+
- Sin contenido ilegal
|
| 48 |
+
- Lenguaje moderado permitido
|
| 49 |
+
|
| 50 |
+
### TikTok:
|
| 51 |
+
- Sin desnudez ni insinuaciones sexuales
|
| 52 |
+
- Sin violencia gráfica
|
| 53 |
+
- Sin contenido de autolesión
|
| 54 |
+
- Sin desinformación
|
| 55 |
+
- Música con licencia únicamente
|
| 56 |
+
|
| 57 |
+
### Instagram:
|
| 58 |
+
- Sin desnudez (arte clásico con moderación)
|
| 59 |
+
- Sin violencia gráfica
|
| 60 |
+
- Sin contenido de autolesión
|
| 61 |
+
- Sin discurso de odio
|
| 62 |
+
- Imágenes editadas deben etiquetarse
|
| 63 |
+
|
| 64 |
+
### Twitter/X:
|
| 65 |
+
- Mayor libertad pero con advertencias
|
| 66 |
+
- Contenido sensible debe marcarse
|
| 67 |
+
- Sin contenido ilegal
|
| 68 |
+
|
| 69 |
+
## FORMATO DE RESPUESTA:
|
| 70 |
+
|
| 71 |
+
Responde SIEMPRE en este formato JSON:
|
| 72 |
+
{
|
| 73 |
+
"type": "image|video|code|text|social",
|
| 74 |
+
"optimizedPrompt": "El prompt optimizado y detallado",
|
| 75 |
+
"suggestions": ["sugerencia1", "sugerencia2"],
|
| 76 |
+
"censorWarnings": ["advertencia1"] o [],
|
| 77 |
+
"platformCompatible": ["youtube", "tiktok", ...],
|
| 78 |
+
"parameters": {
|
| 79 |
+
// Parámetros técnicos recomendados
|
| 80 |
+
}
|
| 81 |
+
}`;
|
| 82 |
+
|
| 83 |
+
export async function POST(request: NextRequest) {
|
| 84 |
+
try {
|
| 85 |
+
const body = await request.json();
|
| 86 |
+
const { prompt, type, platform, character } = body;
|
| 87 |
+
|
| 88 |
+
if (!prompt) {
|
| 89 |
+
return NextResponse.json(
|
| 90 |
+
{ success: false, error: "El prompt es requerido" },
|
| 91 |
+
{ status: 400 }
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const zai = await ZAI.create();
|
| 96 |
+
|
| 97 |
+
// Construir el contexto
|
| 98 |
+
let contextPrompt = prompt;
|
| 99 |
+
if (type) {
|
| 100 |
+
contextPrompt = `Tipo de tarea: ${type}\nSolicitud: ${prompt}`;
|
| 101 |
+
}
|
| 102 |
+
if (platform) {
|
| 103 |
+
contextPrompt += `\nPlataforma destino: ${platform}`;
|
| 104 |
+
}
|
| 105 |
+
if (character) {
|
| 106 |
+
contextPrompt += `\nPersonaje/Referencia: ${character}`;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const completion = await zai.chat.completions.create({
|
| 110 |
+
messages: [
|
| 111 |
+
{ role: "system", content: PROMPT_ENGINEER_SYSTEM },
|
| 112 |
+
{ role: "user", content: contextPrompt }
|
| 113 |
+
],
|
| 114 |
+
temperature: 0.7,
|
| 115 |
+
max_tokens: 2000,
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
const response = completion.choices[0]?.message?.content || "";
|
| 119 |
+
|
| 120 |
+
// Intentar parsear el JSON de la respuesta
|
| 121 |
+
let parsedResponse;
|
| 122 |
+
try {
|
| 123 |
+
// Buscar JSON en la respuesta
|
| 124 |
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
| 125 |
+
if (jsonMatch) {
|
| 126 |
+
parsedResponse = JSON.parse(jsonMatch[0]);
|
| 127 |
+
} else {
|
| 128 |
+
parsedResponse = {
|
| 129 |
+
type: type || "text",
|
| 130 |
+
optimizedPrompt: response,
|
| 131 |
+
suggestions: [],
|
| 132 |
+
censorWarnings: [],
|
| 133 |
+
platformCompatible: ["general"],
|
| 134 |
+
parameters: {}
|
| 135 |
+
};
|
| 136 |
+
}
|
| 137 |
+
} catch {
|
| 138 |
+
parsedResponse = {
|
| 139 |
+
type: type || "text",
|
| 140 |
+
optimizedPrompt: response,
|
| 141 |
+
suggestions: [],
|
| 142 |
+
censorWarnings: [],
|
| 143 |
+
platformCompatible: ["general"],
|
| 144 |
+
parameters: {}
|
| 145 |
+
};
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
return NextResponse.json({
|
| 149 |
+
success: true,
|
| 150 |
+
originalPrompt: prompt,
|
| 151 |
+
...parsedResponse
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
} catch (error) {
|
| 155 |
+
console.error("Error in prompt engineer:", error);
|
| 156 |
+
return NextResponse.json(
|
| 157 |
+
{ success: false, error: "Error al procesar el prompt" },
|
| 158 |
+
{ status: 500 }
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// Endpoint para obtener sugerencias de prompts
|
| 164 |
+
export async function GET(request: NextRequest) {
|
| 165 |
+
const { searchParams } = new URL(request.url);
|
| 166 |
+
const category = searchParams.get("category") || "image";
|
| 167 |
+
|
| 168 |
+
const templates = {
|
| 169 |
+
image: [
|
| 170 |
+
{
|
| 171 |
+
name: "Retrato Profesional",
|
| 172 |
+
template: "Retrato profesional de [persona], iluminación de estudio, fondo neutro, alta calidad, expresión [emoción]",
|
| 173 |
+
variables: ["persona", "emoción"]
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
name: "Escena Cinematográfica",
|
| 177 |
+
template: "Escena cinematográfica de [descripción], iluminación golden hour, atmósfera [mood], estilo película, 4K",
|
| 178 |
+
variables: ["descripción", "mood"]
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
name: "Arte Digital",
|
| 182 |
+
template: "Arte digital de [sujeto], estilo [estilo], colores vibrantes, altamente detallado, trending on artstation",
|
| 183 |
+
variables: ["sujeto", "estilo"]
|
| 184 |
+
}
|
| 185 |
+
],
|
| 186 |
+
video: [
|
| 187 |
+
{
|
| 188 |
+
name: "Video Promocional",
|
| 189 |
+
template: "Video promocional de [producto/servicio], duración 30 segundos, estilo moderno, transiciones suaves",
|
| 190 |
+
variables: ["producto/servicio"]
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
name: "Tutorial Animado",
|
| 194 |
+
template: "Video tutorial animado sobre [tema], estilo infografía, explicación paso a paso, iconos claros",
|
| 195 |
+
variables: ["tema"]
|
| 196 |
+
}
|
| 197 |
+
],
|
| 198 |
+
code: [
|
| 199 |
+
{
|
| 200 |
+
name: "API REST",
|
| 201 |
+
template: "Crear API REST en [lenguaje] con [framework] para [funcionalidad], incluir validación y manejo de errores",
|
| 202 |
+
variables: ["lenguaje", "framework", "funcionalidad"]
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
name: "Componente UI",
|
| 206 |
+
template: "Componente [tipo] en React con TypeScript, props: [props], incluir estados y animaciones",
|
| 207 |
+
variables: ["tipo", "props"]
|
| 208 |
+
}
|
| 209 |
+
]
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
+
return NextResponse.json({
|
| 213 |
+
success: true,
|
| 214 |
+
templates: templates[category as keyof typeof templates] || templates.image
|
| 215 |
+
});
|
| 216 |
+
}
|
src/app/api/repos/[id]/route.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import fs from "fs/promises";
|
| 4 |
+
import path from "path";
|
| 5 |
+
|
| 6 |
+
const REPOS_DIR = path.join(process.cwd(), "repos");
|
| 7 |
+
|
| 8 |
+
// GET - Obtener detalles de un repositorio específico
|
| 9 |
+
export async function GET(
|
| 10 |
+
request: NextRequest,
|
| 11 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 12 |
+
) {
|
| 13 |
+
try {
|
| 14 |
+
const { id } = await params;
|
| 15 |
+
|
| 16 |
+
const repo = await db.repo.findUnique({
|
| 17 |
+
where: { id },
|
| 18 |
+
include: {
|
| 19 |
+
project: true,
|
| 20 |
+
analyses: true,
|
| 21 |
+
},
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
if (!repo) {
|
| 25 |
+
return NextResponse.json(
|
| 26 |
+
{ success: false, error: "Repositorio no encontrado" },
|
| 27 |
+
{ status: 404 }
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// Leer estructura de archivos del repo
|
| 32 |
+
const repoPath = path.join(REPOS_DIR, repo.name);
|
| 33 |
+
let files: string[] = [];
|
| 34 |
+
let readme = null;
|
| 35 |
+
|
| 36 |
+
try {
|
| 37 |
+
const entries = await fs.readdir(repoPath, { withFileTypes: true });
|
| 38 |
+
files = entries.map((e) => e.name);
|
| 39 |
+
|
| 40 |
+
// Intentar leer README
|
| 41 |
+
try {
|
| 42 |
+
const readmePath = path.join(repoPath, "README.md");
|
| 43 |
+
readme = await fs.readFile(readmePath, "utf-8");
|
| 44 |
+
} catch {
|
| 45 |
+
// No hay README
|
| 46 |
+
}
|
| 47 |
+
} catch {
|
| 48 |
+
// Directorio no accesible
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return NextResponse.json({
|
| 52 |
+
success: true,
|
| 53 |
+
repo,
|
| 54 |
+
files,
|
| 55 |
+
readme,
|
| 56 |
+
});
|
| 57 |
+
} catch (error) {
|
| 58 |
+
console.error("Error fetching repo:", error);
|
| 59 |
+
return NextResponse.json(
|
| 60 |
+
{ success: false, error: "Error al obtener repositorio" },
|
| 61 |
+
{ status: 500 }
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// DELETE - Eliminar un repositorio
|
| 67 |
+
export async function DELETE(
|
| 68 |
+
request: NextRequest,
|
| 69 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 70 |
+
) {
|
| 71 |
+
try {
|
| 72 |
+
const { id } = await params;
|
| 73 |
+
|
| 74 |
+
const repo = await db.repo.findUnique({
|
| 75 |
+
where: { id },
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
if (!repo) {
|
| 79 |
+
return NextResponse.json(
|
| 80 |
+
{ success: false, error: "Repositorio no encontrado" },
|
| 81 |
+
{ status: 404 }
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Eliminar directorio del repositorio
|
| 86 |
+
const repoPath = path.join(REPOS_DIR, repo.name);
|
| 87 |
+
try {
|
| 88 |
+
await fs.rm(repoPath, { recursive: true, force: true });
|
| 89 |
+
} catch {
|
| 90 |
+
// Directorio no existe
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Eliminar análisis relacionados
|
| 94 |
+
await db.analysis.deleteMany({
|
| 95 |
+
where: { repoId: id },
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
// Eliminar de base de datos
|
| 99 |
+
await db.repo.delete({
|
| 100 |
+
where: { id },
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
return NextResponse.json({
|
| 104 |
+
success: true,
|
| 105 |
+
message: `Repositorio ${repo.name} eliminado`,
|
| 106 |
+
});
|
| 107 |
+
} catch (error) {
|
| 108 |
+
console.error("Error deleting repo:", error);
|
| 109 |
+
return NextResponse.json(
|
| 110 |
+
{ success: false, error: "Error al eliminar repositorio" },
|
| 111 |
+
{ status: 500 }
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
}
|
src/app/api/repos/route.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import { exec } from "child_process";
|
| 4 |
+
import { promisify } from "util";
|
| 5 |
+
import path from "path";
|
| 6 |
+
import fs from "fs/promises";
|
| 7 |
+
|
| 8 |
+
const execAsync = promisify(exec);
|
| 9 |
+
|
| 10 |
+
const REPOS_DIR = path.join(process.cwd(), "repos");
|
| 11 |
+
|
| 12 |
+
// Asegurar que el directorio existe
|
| 13 |
+
async function ensureReposDir() {
|
| 14 |
+
try {
|
| 15 |
+
await fs.mkdir(REPOS_DIR, { recursive: true });
|
| 16 |
+
} catch (error) {
|
| 17 |
+
// Directorio ya existe
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// GET - Listar todos los repositorios
|
| 22 |
+
export async function GET() {
|
| 23 |
+
try {
|
| 24 |
+
const repos = await db.repo.findMany({
|
| 25 |
+
include: {
|
| 26 |
+
project: true,
|
| 27 |
+
analyses: true,
|
| 28 |
+
},
|
| 29 |
+
orderBy: { createdAt: "desc" },
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
return NextResponse.json({
|
| 33 |
+
success: true,
|
| 34 |
+
repos,
|
| 35 |
+
total: repos.length,
|
| 36 |
+
});
|
| 37 |
+
} catch (error) {
|
| 38 |
+
console.error("Error fetching repos:", error);
|
| 39 |
+
return NextResponse.json(
|
| 40 |
+
{ success: false, error: "Error al obtener repositorios" },
|
| 41 |
+
{ status: 500 }
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// POST - Clonar un nuevo repositorio
|
| 47 |
+
export async function POST(request: NextRequest) {
|
| 48 |
+
try {
|
| 49 |
+
const body = await request.json();
|
| 50 |
+
const { url, projectId } = body;
|
| 51 |
+
|
| 52 |
+
if (!url) {
|
| 53 |
+
return NextResponse.json(
|
| 54 |
+
{ success: false, error: "URL del repositorio es requerida" },
|
| 55 |
+
{ status: 400 }
|
| 56 |
+
);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Extraer nombre del repo de la URL
|
| 60 |
+
const urlParts = url.replace(".git", "").split("/");
|
| 61 |
+
const name = urlParts[urlParts.length - 1] || "unknown-repo";
|
| 62 |
+
|
| 63 |
+
await ensureReposDir();
|
| 64 |
+
const targetPath = path.join(REPOS_DIR, name);
|
| 65 |
+
|
| 66 |
+
// Verificar si ya existe
|
| 67 |
+
try {
|
| 68 |
+
await fs.access(targetPath);
|
| 69 |
+
return NextResponse.json(
|
| 70 |
+
{ success: false, error: "El repositorio ya existe" },
|
| 71 |
+
{ status: 400 }
|
| 72 |
+
);
|
| 73 |
+
} catch {
|
| 74 |
+
// No existe, podemos clonar
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Clonar repositorio
|
| 78 |
+
try {
|
| 79 |
+
await execAsync(`git clone ${url} ${targetPath}`, {
|
| 80 |
+
timeout: 60000,
|
| 81 |
+
});
|
| 82 |
+
} catch (cloneError) {
|
| 83 |
+
console.error("Clone error:", cloneError);
|
| 84 |
+
return NextResponse.json(
|
| 85 |
+
{ success: false, error: "Error al clonar el repositorio" },
|
| 86 |
+
{ status: 500 }
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Guardar en base de datos
|
| 91 |
+
const repo = await db.repo.create({
|
| 92 |
+
data: {
|
| 93 |
+
url,
|
| 94 |
+
name,
|
| 95 |
+
status: "cloned",
|
| 96 |
+
projectId: projectId || null,
|
| 97 |
+
},
|
| 98 |
+
include: {
|
| 99 |
+
project: true,
|
| 100 |
+
},
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// Crear tarea de agente
|
| 104 |
+
await db.agentTask.create({
|
| 105 |
+
data: {
|
| 106 |
+
type: "clone",
|
| 107 |
+
status: "completed",
|
| 108 |
+
input: url,
|
| 109 |
+
output: `Repositorio ${name} clonado exitosamente`,
|
| 110 |
+
completedAt: new Date(),
|
| 111 |
+
},
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
return NextResponse.json({
|
| 115 |
+
success: true,
|
| 116 |
+
repo,
|
| 117 |
+
message: `Repositorio ${name} clonado exitosamente`,
|
| 118 |
+
});
|
| 119 |
+
} catch (error) {
|
| 120 |
+
console.error("Error in POST repos:", error);
|
| 121 |
+
return NextResponse.json(
|
| 122 |
+
{ success: false, error: "Error interno del servidor" },
|
| 123 |
+
{ status: 500 }
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
}
|
src/app/api/route.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
export async function GET() {
|
| 4 |
+
return NextResponse.json({ message: "Hello, world!" });
|
| 5 |
+
}
|
src/app/api/storytelling/route.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 4 |
+
|
| 5 |
+
const STORYTELLING_SYSTEM = `Eres un experto guionista de contenido digital especializado en crear narrativas atractivas para redes sociales y plataformas de monetización.
|
| 6 |
+
|
| 7 |
+
## TUS ESPECIALIDADES:
|
| 8 |
+
|
| 9 |
+
### 1. ESTRUCTURA NARRATIVA
|
| 10 |
+
Cada historia debe tener:
|
| 11 |
+
- **Hook inicial**: Gancho que capture la atención en los primeros 3 segundos
|
| 12 |
+
- **Desarrollo**: Progresión emocional con picos y valles
|
| 13 |
+
- **Cliffhanger**: Suspense que mantenga a la audiencia esperando más
|
| 14 |
+
- **Resolución**: Satisfacción pero dejando puerta abierta
|
| 15 |
+
|
| 16 |
+
### 2. TÉCNICAS DE ENGAGEMENT
|
| 17 |
+
- Preguntas retóricas
|
| 18 |
+
- Revelaciones progresivas
|
| 19 |
+
- Conflictos emocionales
|
| 20 |
+
- Personajes identificables
|
| 21 |
+
- Giros inesperados
|
| 22 |
+
- Calls to action sutiles
|
| 23 |
+
|
| 24 |
+
### 3. OPTIMIZACIÓN POR PLATAFORMA
|
| 25 |
+
- **Instagram Reels**: Historias de 15-60 seg, visual impactante
|
| 26 |
+
- **TikTok**: Tendencias, humor, velocidad, authenticy
|
| 27 |
+
- **YouTube**: Mayor profundidad, series, communities
|
| 28 |
+
- **OnlyFans**: Exclusividad, conexión personal, behind the scenes
|
| 29 |
+
- **Patreon**: Contenido premium, tutoriales, acceso VIP
|
| 30 |
+
|
| 31 |
+
### 4. ESTRATEGIAS DE MONETIZACIÓN
|
| 32 |
+
- Teasers gratuitos + contenido premium
|
| 33 |
+
- Suscripción escalonada
|
| 34 |
+
- Contenido exclusivo para tiers superiores
|
| 35 |
+
- PPV (Pay Per View) para contenido especial
|
| 36 |
+
- Fan engagement strategies
|
| 37 |
+
|
| 38 |
+
### 5. TIPOS DE CONTENIDO
|
| 39 |
+
- **Lifestyle**: Day in the life, routines, personal growth
|
| 40 |
+
- **Drama**: Relationships, conflicts, resolutions
|
| 41 |
+
- **Tutorial**: How-to, tips, behind the scenes
|
| 42 |
+
- **Challenge**: Personal challenges, transformations
|
| 43 |
+
- **ASMR/Relaxation**: Calm content, soothing narratives
|
| 44 |
+
- **Entertainment**: Comedy, reactions, trends
|
| 45 |
+
|
| 46 |
+
## FORMATO DE RESPUESTA:
|
| 47 |
+
Responde siempre en JSON con esta estructura:
|
| 48 |
+
{
|
| 49 |
+
"title": "Título de la historia",
|
| 50 |
+
"synopsis": "Resumen breve",
|
| 51 |
+
"genre": "género",
|
| 52 |
+
"targetAudience": "audiencia objetivo",
|
| 53 |
+
"tone": "tono narrativo",
|
| 54 |
+
"totalEpisodes": número,
|
| 55 |
+
"episodes": [
|
| 56 |
+
{
|
| 57 |
+
"episodeNum": 1,
|
| 58 |
+
"title": "Título episodio",
|
| 59 |
+
"hook": "Gancho inicial",
|
| 60 |
+
"content": "Contenido del episodio",
|
| 61 |
+
"cliffhanger": "Suspense final",
|
| 62 |
+
"bestPostingTime": "hora",
|
| 63 |
+
"estimatedEngagement": "alto/medio/bajo",
|
| 64 |
+
"monetizationTip": "Sugerencia de monetización"
|
| 65 |
+
}
|
| 66 |
+
],
|
| 67 |
+
"monetizationStrategy": {
|
| 68 |
+
"freeTeasers": [números de episodios gratis],
|
| 69 |
+
"premiumContent": [números de episodios premium],
|
| 70 |
+
"suggestedPlatform": "plataforma recomendada",
|
| 71 |
+
"pricingStrategy": "estrategia de precios"
|
| 72 |
+
},
|
| 73 |
+
"hashtags": ["hashtags relevantes"],
|
| 74 |
+
"bestPostingSchedule": ["horarios recomendados"]
|
| 75 |
+
}`;
|
| 76 |
+
|
| 77 |
+
// GET - Listar historias
|
| 78 |
+
export async function GET(request: NextRequest) {
|
| 79 |
+
try {
|
| 80 |
+
const { searchParams } = new URL(request.url);
|
| 81 |
+
const status = searchParams.get("status");
|
| 82 |
+
const genre = searchParams.get("genre");
|
| 83 |
+
|
| 84 |
+
const where: Record<string, unknown> = {};
|
| 85 |
+
if (status) where.status = status;
|
| 86 |
+
if (genre) where.genre = genre;
|
| 87 |
+
|
| 88 |
+
const stories = await db.story.findMany({
|
| 89 |
+
where,
|
| 90 |
+
include: {
|
| 91 |
+
episodes: {
|
| 92 |
+
orderBy: { episodeNum: "asc" }
|
| 93 |
+
},
|
| 94 |
+
analytics: true,
|
| 95 |
+
_count: {
|
| 96 |
+
select: { posts: true, episodes: true }
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
orderBy: { createdAt: "desc" }
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
// Géneros disponibles
|
| 103 |
+
const genres = [
|
| 104 |
+
{ id: "romance", name: "Romance", description: "Historias de amor y relaciones" },
|
| 105 |
+
{ id: "drama", name: "Drama", description: "Conflictos emocionales intensos" },
|
| 106 |
+
{ id: "comedy", name: "Comedia", description: "Contenido humorístico" },
|
| 107 |
+
{ id: "thriller", name: "Thriller", description: "Suspenso y misterio" },
|
| 108 |
+
{ id: "lifestyle", name: "Lifestyle", description: "Vida cotidiana y rutinas" },
|
| 109 |
+
{ id: "fitness", name: "Fitness", description: "Transformaciones y salud" },
|
| 110 |
+
{ id: "beauty", name: "Beauty", description: "Belleza y cuidados" },
|
| 111 |
+
{ id: "cooking", name: "Cooking", description: "Cocina y recetas" },
|
| 112 |
+
{ id: "travel", name: "Travel", description: "Viajes y aventuras" },
|
| 113 |
+
{ id: "gaming", name: "Gaming", description: "Videojuegos y streaming" },
|
| 114 |
+
{ id: "education", name: "Educación", description: "Contenido educativo" },
|
| 115 |
+
{ id: "asmr", name: "ASMR", description: "Relajación y calma" },
|
| 116 |
+
];
|
| 117 |
+
|
| 118 |
+
return NextResponse.json({
|
| 119 |
+
success: true,
|
| 120 |
+
stories,
|
| 121 |
+
genres,
|
| 122 |
+
total: stories.length
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
} catch (error) {
|
| 126 |
+
console.error("Error fetching stories:", error);
|
| 127 |
+
return NextResponse.json(
|
| 128 |
+
{ success: false, error: "Error al obtener historias" },
|
| 129 |
+
{ status: 500 }
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// POST - Crear nueva historia con IA
|
| 135 |
+
export async function POST(request: NextRequest) {
|
| 136 |
+
try {
|
| 137 |
+
const body = await request.json();
|
| 138 |
+
const {
|
| 139 |
+
prompt,
|
| 140 |
+
genre,
|
| 141 |
+
targetAudience,
|
| 142 |
+
tone,
|
| 143 |
+
totalEpisodes,
|
| 144 |
+
platform,
|
| 145 |
+
characterIds,
|
| 146 |
+
monetizationGoal
|
| 147 |
+
} = body;
|
| 148 |
+
|
| 149 |
+
if (!prompt) {
|
| 150 |
+
return NextResponse.json(
|
| 151 |
+
{ success: false, error: "El prompt es requerido" },
|
| 152 |
+
{ status: 400 }
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const zai = await ZAI.create();
|
| 157 |
+
|
| 158 |
+
// Generar historia completa con IA
|
| 159 |
+
const completion = await zai.chat.completions.create({
|
| 160 |
+
messages: [
|
| 161 |
+
{ role: "system", content: STORYTELLING_SYSTEM },
|
| 162 |
+
{ role: "user", content: `Crea una historia con los siguientes parámetros:
|
| 163 |
+
|
| 164 |
+
Concepto: ${prompt}
|
| 165 |
+
${genre ? `Género: ${genre}` : ''}
|
| 166 |
+
${targetAudience ? `Audiencia objetivo: ${targetAudience}` : ''}
|
| 167 |
+
${tone ? `Tono: ${tone}` : ''}
|
| 168 |
+
${totalEpisodes ? `Número de episodios: ${totalEpisodes}` : 'Número de episodios: 7'}
|
| 169 |
+
${platform ? `Plataforma principal: ${platform}` : ''}
|
| 170 |
+
${monetizationGoal ? `Objetivo de monetización: ${monetizationGoal}` : 'Objetivo: Maximizar suscriptores pagos'}
|
| 171 |
+
|
| 172 |
+
Genera la historia completa con todos los episodios y estrategia de monetización.` }
|
| 173 |
+
],
|
| 174 |
+
temperature: 0.8,
|
| 175 |
+
max_tokens: 6000,
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
const response = completion.choices[0]?.message?.content || "";
|
| 179 |
+
|
| 180 |
+
// Parsear respuesta
|
| 181 |
+
let storyData;
|
| 182 |
+
try {
|
| 183 |
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
| 184 |
+
if (jsonMatch) {
|
| 185 |
+
storyData = JSON.parse(jsonMatch[0]);
|
| 186 |
+
}
|
| 187 |
+
} catch (parseError) {
|
| 188 |
+
console.error("Error parsing story:", parseError);
|
| 189 |
+
storyData = {
|
| 190 |
+
title: "Historia generada",
|
| 191 |
+
synopsis: prompt,
|
| 192 |
+
episodes: []
|
| 193 |
+
};
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Crear historia en BD
|
| 197 |
+
const story = await db.story.create({
|
| 198 |
+
data: {
|
| 199 |
+
title: storyData.title || "Nueva Historia",
|
| 200 |
+
description: storyData.synopsis || prompt,
|
| 201 |
+
genre: storyData.genre || genre || "lifestyle",
|
| 202 |
+
targetAudience: storyData.targetAudience || targetAudience || "general",
|
| 203 |
+
tone: storyData.tone || tone || "casual",
|
| 204 |
+
totalEpisodes: storyData.totalEpisodes || storyData.episodes?.length || 7,
|
| 205 |
+
characterIds: characterIds ? JSON.stringify(characterIds) : null,
|
| 206 |
+
monetizationStrategy: storyData.monetizationStrategy ? JSON.stringify(storyData.monetizationStrategy) : null,
|
| 207 |
+
status: "draft",
|
| 208 |
+
}
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
// Crear episodios
|
| 212 |
+
if (storyData.episodes && Array.isArray(storyData.episodes)) {
|
| 213 |
+
for (const ep of storyData.episodes) {
|
| 214 |
+
await db.storyEpisode.create({
|
| 215 |
+
data: {
|
| 216 |
+
storyId: story.id,
|
| 217 |
+
episodeNum: ep.episodeNum || storyData.episodes.indexOf(ep) + 1,
|
| 218 |
+
title: ep.title || `Episodio ${ep.episodeNum}`,
|
| 219 |
+
synopsis: ep.hook || null,
|
| 220 |
+
content: ep.content || "",
|
| 221 |
+
hook: ep.hook || null,
|
| 222 |
+
cliffhanger: ep.cliffhanger || null,
|
| 223 |
+
status: "draft",
|
| 224 |
+
}
|
| 225 |
+
});
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// Crear analytics
|
| 230 |
+
await db.storyAnalytics.create({
|
| 231 |
+
data: {
|
| 232 |
+
storyId: story.id,
|
| 233 |
+
}
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
// Crear tarea de agente
|
| 237 |
+
await db.agentTask.create({
|
| 238 |
+
data: {
|
| 239 |
+
type: "create_story",
|
| 240 |
+
status: "completed",
|
| 241 |
+
input: prompt,
|
| 242 |
+
output: `Historia "${storyData.title}" creada con ${storyData.episodes?.length || 0} episodios`,
|
| 243 |
+
completedAt: new Date(),
|
| 244 |
+
}
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
// Obtener historia completa
|
| 248 |
+
const fullStory = await db.story.findUnique({
|
| 249 |
+
where: { id: story.id },
|
| 250 |
+
include: {
|
| 251 |
+
episodes: { orderBy: { episodeNum: "asc" } },
|
| 252 |
+
analytics: true
|
| 253 |
+
}
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
return NextResponse.json({
|
| 257 |
+
success: true,
|
| 258 |
+
story: fullStory,
|
| 259 |
+
rawAIResponse: storyData,
|
| 260 |
+
message: `Historia "${storyData.title}" creada exitosamente`
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
+
} catch (error) {
|
| 264 |
+
console.error("Error creating story:", error);
|
| 265 |
+
return NextResponse.json(
|
| 266 |
+
{ success: false, error: "Error al crear historia" },
|
| 267 |
+
{ status: 500 }
|
| 268 |
+
);
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// PUT - Actualizar historia
|
| 273 |
+
export async function PUT(request: NextRequest) {
|
| 274 |
+
try {
|
| 275 |
+
const body = await request.json();
|
| 276 |
+
const { id, status, currentEpisode } = body;
|
| 277 |
+
|
| 278 |
+
if (!id) {
|
| 279 |
+
return NextResponse.json(
|
| 280 |
+
{ success: false, error: "ID requerido" },
|
| 281 |
+
{ status: 400 }
|
| 282 |
+
);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
const story = await db.story.update({
|
| 286 |
+
where: { id },
|
| 287 |
+
data: {
|
| 288 |
+
status: status || undefined,
|
| 289 |
+
currentEpisode: currentEpisode || undefined,
|
| 290 |
+
}
|
| 291 |
+
});
|
| 292 |
+
|
| 293 |
+
return NextResponse.json({
|
| 294 |
+
success: true,
|
| 295 |
+
story
|
| 296 |
+
});
|
| 297 |
+
|
| 298 |
+
} catch (error) {
|
| 299 |
+
console.error("Error updating story:", error);
|
| 300 |
+
return NextResponse.json(
|
| 301 |
+
{ success: false, error: "Error al actualizar historia" },
|
| 302 |
+
{ status: 500 }
|
| 303 |
+
);
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// DELETE - Eliminar historia
|
| 308 |
+
export async function DELETE(request: NextRequest) {
|
| 309 |
+
try {
|
| 310 |
+
const { searchParams } = new URL(request.url);
|
| 311 |
+
const id = searchParams.get("id");
|
| 312 |
+
|
| 313 |
+
if (!id) {
|
| 314 |
+
return NextResponse.json(
|
| 315 |
+
{ success: false, error: "ID requerido" },
|
| 316 |
+
{ status: 400 }
|
| 317 |
+
);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
// Eliminar episodios primero
|
| 321 |
+
await db.storyEpisode.deleteMany({
|
| 322 |
+
where: { storyId: id }
|
| 323 |
+
});
|
| 324 |
+
|
| 325 |
+
// Eliminar analytics
|
| 326 |
+
await db.storyAnalytics.deleteMany({
|
| 327 |
+
where: { storyId: id }
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
// Eliminar historia
|
| 331 |
+
await db.story.delete({
|
| 332 |
+
where: { id }
|
| 333 |
+
});
|
| 334 |
+
|
| 335 |
+
return NextResponse.json({
|
| 336 |
+
success: true,
|
| 337 |
+
message: "Historia eliminada"
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
} catch (error) {
|
| 341 |
+
console.error("Error deleting story:", error);
|
| 342 |
+
return NextResponse.json(
|
| 343 |
+
{ success: false, error: "Error al eliminar historia" },
|
| 344 |
+
{ status: 500 }
|
| 345 |
+
);
|
| 346 |
+
}
|
| 347 |
+
}
|
src/app/api/trends/route.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
+
import { prisma } from "@/lib/db";
|
| 4 |
+
|
| 5 |
+
// Tendencias actuales por plataforma (datos simulados actualizados)
|
| 6 |
+
const TRENDING_DATA = {
|
| 7 |
+
instagram: [
|
| 8 |
+
{ name: "#aesthetic", type: "hashtag", volume: 2500000, growth: 15.5, category: "lifestyle" },
|
| 9 |
+
{ name: "#reelsviral", type: "hashtag", volume: 1800000, growth: 22.3, category: "video" },
|
| 10 |
+
{ name: "#lifestyle", type: "hashtag", volume: 3200000, growth: 8.2, category: "lifestyle" },
|
| 11 |
+
{ name: "ASMR content", type: "topic", volume: 890000, growth: 45.2, category: "relaxation" },
|
| 12 |
+
{ name: "Day in my life", type: "topic", volume: 1200000, growth: 18.7, category: "vlog" },
|
| 13 |
+
{ name: "Get Ready With Me", type: "format", volume: 2100000, growth: 25.3, category: "beauty" },
|
| 14 |
+
{ name: "Photo dumps", type: "format", volume: 1500000, growth: 32.1, category: "lifestyle" },
|
| 15 |
+
{ name: "Pets content", type: "topic", volume: 980000, growth: 55.8, category: "pets" },
|
| 16 |
+
],
|
| 17 |
+
tiktok: [
|
| 18 |
+
{ name: "#fyp", type: "hashtag", volume: 15000000, growth: 5.2, category: "general" },
|
| 19 |
+
{ name: "#viral", type: "hashtag", volume: 12000000, growth: 12.8, category: "general" },
|
| 20 |
+
{ name: "Storytime", type: "topic", volume: 3500000, growth: 32.1, category: "narrative" },
|
| 21 |
+
{ name: "GRWM", type: "format", volume: 2800000, growth: 28.5, category: "beauty" },
|
| 22 |
+
{ name: "POV videos", type: "format", volume: 4200000, growth: 19.3, category: "creative" },
|
| 23 |
+
{ name: "Dance challenges", type: "format", volume: 5200000, growth: 15.7, category: "dance" },
|
| 24 |
+
{ name: "Pet reveals", type: "topic", volume: 1800000, growth: 48.3, category: "pets" },
|
| 25 |
+
{ name: "Before/After", type: "format", volume: 2200000, growth: 35.2, category: "transformation" },
|
| 26 |
+
],
|
| 27 |
+
youtube: [
|
| 28 |
+
{ name: "Shorts", type: "format", volume: 8500000, growth: 25.6, category: "video" },
|
| 29 |
+
{ name: "Tutorial", type: "topic", volume: 5200000, growth: 12.4, category: "education" },
|
| 30 |
+
{ name: "Vlog", type: "topic", volume: 3800000, growth: 8.9, category: "lifestyle" },
|
| 31 |
+
{ name: "Reaction videos", type: "format", volume: 2100000, growth: 15.7, category: "entertainment" },
|
| 32 |
+
{ name: "ASMR", type: "topic", volume: 3200000, growth: 28.4, category: "relaxation" },
|
| 33 |
+
{ name: "Pet compilations", type: "format", volume: 1500000, growth: 42.1, category: "pets" },
|
| 34 |
+
],
|
| 35 |
+
onlyfans: [
|
| 36 |
+
{ name: "Behind the scenes", type: "topic", volume: 450000, growth: 35.2, category: "exclusive" },
|
| 37 |
+
{ name: "Exclusive content", type: "topic", volume: 380000, growth: 28.7, category: "premium" },
|
| 38 |
+
{ name: "PPV specials", type: "format", volume: 220000, growth: 42.1, category: "monetization" },
|
| 39 |
+
{ name: "Fan engagement", type: "topic", volume: 180000, growth: 55.3, category: "community" },
|
| 40 |
+
{ name: "Pet content", type: "topic", volume: 95000, growth: 62.5, category: "niche" },
|
| 41 |
+
]
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
// Estrategias virales conocidas
|
| 45 |
+
const VIRAL_STRATEGIES = [
|
| 46 |
+
{
|
| 47 |
+
name: "Hook en 3 segundos",
|
| 48 |
+
description: "Captar atención en los primeros 3 segundos con algo visualmente impactante o una pregunta intrigante",
|
| 49 |
+
platforms: ["tiktok", "instagram", "youtube"],
|
| 50 |
+
contentType: "video",
|
| 51 |
+
successRate: 85,
|
| 52 |
+
elements: ["pregunta intrigante", "revelación parcial", "movimiento brusco", "sonido llamativo"]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
name: "Storytime con suspenso",
|
| 56 |
+
description: "Narrar una historia personal manteniendo el suspenso hasta el final",
|
| 57 |
+
platforms: ["tiktok", "youtube"],
|
| 58 |
+
contentType: "video",
|
| 59 |
+
successRate: 78,
|
| 60 |
+
elements: ["gancho inicial", "pausas dramáticas", "cliffhanger", "resolución satisfactoria"]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
name: "Transformación/Before & After",
|
| 64 |
+
description: "Mostrar un cambio dramático que genere curiosidad sobre el proceso",
|
| 65 |
+
platforms: ["instagram", "tiktok", "youtube"],
|
| 66 |
+
contentType: "reel",
|
| 67 |
+
successRate: 82,
|
| 68 |
+
elements: ["estado inicial", "proceso acelerado", "revelación final", "reacción"]
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
name: "Pet Reveal/Cameo",
|
| 72 |
+
description: "Incluir una mascota de forma natural en el contenido para aumentar engagement",
|
| 73 |
+
platforms: ["instagram", "tiktok"],
|
| 74 |
+
contentType: "any",
|
| 75 |
+
successRate: 88,
|
| 76 |
+
elements: ["mascota apareciendo", "interacción tierna", "momento cómico", "call to action con la mascota"]
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
name: "Controversia controlada",
|
| 80 |
+
description: "Plantear una opinión divisiva de forma respetuosa para generar debate",
|
| 81 |
+
platforms: ["tiktok", "twitter", "youtube"],
|
| 82 |
+
contentType: "opinion",
|
| 83 |
+
successRate: 72,
|
| 84 |
+
elements: ["opinion fuerte", "argumentos", "invitación al debate", "respuesta en comentarios"]
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
name: "Trend hopping",
|
| 88 |
+
description: "Participar en tendencias vigentes con un giro único personal",
|
| 89 |
+
platforms: ["tiktok", "instagram"],
|
| 90 |
+
contentType: "any",
|
| 91 |
+
successRate: 75,
|
| 92 |
+
elements: ["trend actual", "adaptación personal", "elemento sorpresa", "timing perfecto"]
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
name: "BTS/Exclusivo",
|
| 96 |
+
description: "Mostrar el detrás de cámaras o contenido que parece exclusivo",
|
| 97 |
+
platforms: ["instagram", "onlyfans", "youtube"],
|
| 98 |
+
contentType: "video",
|
| 99 |
+
successRate: 80,
|
| 100 |
+
elements: ["acceso VIP", "momentos espontáneos", "errores incluidos", "autenticidad"]
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
name: "Desafío/Reto",
|
| 104 |
+
description: "Crear o participar en un desafío que invite a la participación",
|
| 105 |
+
platforms: ["tiktok", "instagram"],
|
| 106 |
+
contentType: "reel",
|
| 107 |
+
successRate: 77,
|
| 108 |
+
elements: ["reglas claras", "ejemplo viral", "tag a amigos", "fácil de replicar"]
|
| 109 |
+
}
|
| 110 |
+
];
|
| 111 |
+
|
| 112 |
+
// GET - Obtener tendencias y estrategias
|
| 113 |
+
export async function GET(request: NextRequest) {
|
| 114 |
+
try {
|
| 115 |
+
const { searchParams } = new URL(request.url);
|
| 116 |
+
const platform = searchParams.get("platform");
|
| 117 |
+
const type = searchParams.get("type");
|
| 118 |
+
const category = searchParams.get("category");
|
| 119 |
+
const includePets = searchParams.get("includePets") === "true";
|
| 120 |
+
|
| 121 |
+
let trends = [];
|
| 122 |
+
|
| 123 |
+
if (platform && TRENDING_DATA[platform as keyof typeof TRENDING_DATA]) {
|
| 124 |
+
trends = TRENDING_DATA[platform as keyof typeof TRENDING_DATA];
|
| 125 |
+
} else {
|
| 126 |
+
// Combinar todas las plataformas
|
| 127 |
+
trends = Object.entries(TRENDING_DATA).flatMap(([plat, data]) =>
|
| 128 |
+
data.map(t => ({ ...t, platform: plat }))
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// Filtrar por tipo
|
| 133 |
+
if (type) {
|
| 134 |
+
trends = trends.filter(t => t.type === type);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Filtrar por categoría
|
| 138 |
+
if (category) {
|
| 139 |
+
trends = trends.filter(t => t.category === category);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// Filtrar tendencias relacionadas con mascotas
|
| 143 |
+
if (includePets) {
|
| 144 |
+
trends = trends.filter(t =>
|
| 145 |
+
t.category === "pets" ||
|
| 146 |
+
t.name.toLowerCase().includes("pet") ||
|
| 147 |
+
t.name.toLowerCase().includes("dog") ||
|
| 148 |
+
t.name.toLowerCase().includes("cat")
|
| 149 |
+
);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// Ordenar por crecimiento
|
| 153 |
+
trends.sort((a, b) => b.growth - a.growth);
|
| 154 |
+
|
| 155 |
+
// Obtener estrategias virales relevantes
|
| 156 |
+
let strategies = [...VIRAL_STRATEGIES];
|
| 157 |
+
if (platform) {
|
| 158 |
+
strategies = strategies.filter(s =>
|
| 159 |
+
s.platforms.includes(platform.toLowerCase())
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
if (includePets) {
|
| 163 |
+
// Priorizar estrategias que funcionan con mascotas
|
| 164 |
+
strategies.sort((a, b) => {
|
| 165 |
+
const aPet = a.name.toLowerCase().includes("pet") ? 1 : 0;
|
| 166 |
+
const bPet = b.name.toLowerCase().includes("pet") ? 1 : 0;
|
| 167 |
+
return bPet - aPet;
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// Generar ideas de contenido con IA para las tendencias principales
|
| 172 |
+
const zai = await ZAI.create();
|
| 173 |
+
const topTrends = trends.slice(0, 5);
|
| 174 |
+
|
| 175 |
+
const ideasResponse = await zai.chat.completions.create({
|
| 176 |
+
messages: [
|
| 177 |
+
{
|
| 178 |
+
role: "system",
|
| 179 |
+
content: `Eres un experto en marketing de contenidos viral. Genera ideas de contenido basadas en tendencias.
|
| 180 |
+
${includePets ? "IMPORTANTE: Todas las ideas deben incluir una mascota como elemento central." : ""}
|
| 181 |
+
Responde en JSON array con objetos {title, description, format, estimatedEngagement, viralScore, hook}.`
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
role: "user",
|
| 185 |
+
content: `Genera 5 ideas de contenido para estas tendencias: ${topTrends.map(t => t.name).join(", ")}
|
| 186 |
+
Plataforma principal: ${platform || "todas"}
|
| 187 |
+
${includePets ? "Incluir mascota en todas las ideas." : ""}`
|
| 188 |
+
}
|
| 189 |
+
],
|
| 190 |
+
temperature: 0.8,
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
let contentIdeas = [];
|
| 194 |
+
try {
|
| 195 |
+
const match = ideasResponse.choices[0]?.message?.content?.match(/\[[\s\S]*\]/);
|
| 196 |
+
if (match) {
|
| 197 |
+
contentIdeas = JSON.parse(match[0]);
|
| 198 |
+
}
|
| 199 |
+
} catch {
|
| 200 |
+
contentIdeas = [];
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
return NextResponse.json({
|
| 204 |
+
success: true,
|
| 205 |
+
trends,
|
| 206 |
+
viralStrategies: strategies,
|
| 207 |
+
contentIdeas,
|
| 208 |
+
platform: platform || "all",
|
| 209 |
+
stats: {
|
| 210 |
+
totalTrends: trends.length,
|
| 211 |
+
topGrowth: trends[0]?.growth || 0,
|
| 212 |
+
avgGrowth: trends.reduce((acc, t) => acc + t.growth, 0) / trends.length || 0
|
| 213 |
+
}
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
} catch (error) {
|
| 217 |
+
console.error("Error fetching trends:", error);
|
| 218 |
+
return NextResponse.json(
|
| 219 |
+
{ success: false, error: "Error al obtener tendencias" },
|
| 220 |
+
{ status: 500 }
|
| 221 |
+
);
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// POST - Analizar tendencias con IA y generar plan viral
|
| 226 |
+
export async function POST(request: NextRequest) {
|
| 227 |
+
try {
|
| 228 |
+
const body = await request.json();
|
| 229 |
+
const { niche, platform, targetAudience, includePets, petType, daysToViral } = body;
|
| 230 |
+
|
| 231 |
+
const zai = await ZAI.create();
|
| 232 |
+
|
| 233 |
+
const completion = await zai.chat.completions.create({
|
| 234 |
+
messages: [
|
| 235 |
+
{
|
| 236 |
+
role: "system",
|
| 237 |
+
content: `Eres un analista de tendencias de redes sociales y experto en crear contenido viral. Analiza y predice tendencias para creadores de contenido.
|
| 238 |
+
${includePets ? "El creador tiene una mascota que quiere integrar en su contenido." : ""}
|
| 239 |
+
Responde en JSON con esta estructura:
|
| 240 |
+
{
|
| 241 |
+
"currentTrends": [{"name", "type", "growth", "saturation"}],
|
| 242 |
+
"emergingTrends": [{"name", "type", "potential"}],
|
| 243 |
+
"contentGaps": [{"topic", "opportunity", "difficulty"}],
|
| 244 |
+
"recommendations": ["rec1", "rec2"],
|
| 245 |
+
"bestPostingTimes": ["time1", "time2"],
|
| 246 |
+
"hashtagStrategy": {"primary": [], "secondary": []},
|
| 247 |
+
"viralPlan": {
|
| 248 |
+
"week1": [{"day", "contentType", "topic", "hook"}],
|
| 249 |
+
"week2": [{"day", "contentType", "topic", "hook"}]
|
| 250 |
+
},
|
| 251 |
+
"petIntegration": ${includePets ? `[{"tip", "contentIdea", "engagementPotential"}]` : "null"},
|
| 252 |
+
"predictedViralPotential": 0-100,
|
| 253 |
+
"keySuccessFactors": []
|
| 254 |
+
}`
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
role: "user",
|
| 258 |
+
content: `Analiza tendencias para:
|
| 259 |
+
Nicho: ${niche || "general"}
|
| 260 |
+
Plataforma: ${platform || "todas"}
|
| 261 |
+
Audiencia: ${targetAudience || "general"}
|
| 262 |
+
${includePets ? `Incluir mascota tipo: ${petType || "perro/gato"}` : ""}
|
| 263 |
+
Días objetivo para viralizar: ${daysToViral || 14}`
|
| 264 |
+
}
|
| 265 |
+
],
|
| 266 |
+
temperature: 0.7,
|
| 267 |
+
max_tokens: 3000,
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
const response = completion.choices[0]?.message?.content || "";
|
| 271 |
+
let analysis;
|
| 272 |
+
try {
|
| 273 |
+
const match = response.match(/\{[\s\S]*\}/);
|
| 274 |
+
if (match) {
|
| 275 |
+
analysis = JSON.parse(match[0]);
|
| 276 |
+
}
|
| 277 |
+
} catch {
|
| 278 |
+
analysis = { raw: response };
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// Guardar análisis en la base de datos
|
| 282 |
+
await prisma.trend.create({
|
| 283 |
+
data: {
|
| 284 |
+
platform: platform || "all",
|
| 285 |
+
type: "analysis",
|
| 286 |
+
name: `Análisis ${niche || "general"} - ${new Date().toISOString().split('T')[0]}`,
|
| 287 |
+
description: `Análisis de tendencias para nicho: ${niche}`,
|
| 288 |
+
contentIdeas: JSON.stringify(analysis),
|
| 289 |
+
isActive: true
|
| 290 |
+
}
|
| 291 |
+
}).catch(() => {
|
| 292 |
+
// Ignorar errores de guardado
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
return NextResponse.json({
|
| 296 |
+
success: true,
|
| 297 |
+
analysis,
|
| 298 |
+
timestamp: new Date().toISOString()
|
| 299 |
+
});
|
| 300 |
+
|
| 301 |
+
} catch (error) {
|
| 302 |
+
console.error("Error analyzing trends:", error);
|
| 303 |
+
return NextResponse.json(
|
| 304 |
+
{ success: false, error: "Error al analizar tendencias" },
|
| 305 |
+
{ status: 500 }
|
| 306 |
+
);
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// PUT - Crear estrategia viral personalizada
|
| 311 |
+
export async function PUT(request: NextRequest) {
|
| 312 |
+
try {
|
| 313 |
+
const body = await request.json();
|
| 314 |
+
const { name, platform, contentType, hook, structure, elements, estimatedReach, timeframe } = body;
|
| 315 |
+
|
| 316 |
+
const strategy = await prisma.viralStrategy.create({
|
| 317 |
+
data: {
|
| 318 |
+
name,
|
| 319 |
+
platform,
|
| 320 |
+
contentType,
|
| 321 |
+
hook,
|
| 322 |
+
structure: structure ? JSON.stringify(structure) : null,
|
| 323 |
+
elements: elements ? JSON.stringify(elements) : null,
|
| 324 |
+
estimatedReach,
|
| 325 |
+
timeframe,
|
| 326 |
+
isActive: true
|
| 327 |
+
}
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
return NextResponse.json({
|
| 331 |
+
success: true,
|
| 332 |
+
strategy,
|
| 333 |
+
message: "Estrategia viral creada correctamente"
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
} catch (error) {
|
| 337 |
+
console.error("Error creating viral strategy:", error);
|
| 338 |
+
return NextResponse.json(
|
| 339 |
+
{ success: false, error: "Error al crear estrategia" },
|
| 340 |
+
{ status: 500 }
|
| 341 |
+
);
|
| 342 |
+
}
|
| 343 |
+
}
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
@import "tw-animate-css";
|
| 3 |
+
|
| 4 |
+
@custom-variant dark (&:is(.dark *));
|
| 5 |
+
|
| 6 |
+
@theme inline {
|
| 7 |
+
--color-background: var(--background);
|
| 8 |
+
--color-foreground: var(--foreground);
|
| 9 |
+
--font-sans: var(--font-geist-sans);
|
| 10 |
+
--font-mono: var(--font-geist-mono);
|
| 11 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 12 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 13 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 14 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 15 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 16 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 17 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 18 |
+
--color-sidebar: var(--sidebar);
|
| 19 |
+
--color-chart-5: var(--chart-5);
|
| 20 |
+
--color-chart-4: var(--chart-4);
|
| 21 |
+
--color-chart-3: var(--chart-3);
|
| 22 |
+
--color-chart-2: var(--chart-2);
|
| 23 |
+
--color-chart-1: var(--chart-1);
|
| 24 |
+
--color-ring: var(--ring);
|
| 25 |
+
--color-input: var(--input);
|
| 26 |
+
--color-border: var(--border);
|
| 27 |
+
--color-destructive: var(--destructive);
|
| 28 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 29 |
+
--color-accent: var(--accent);
|
| 30 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 31 |
+
--color-muted: var(--muted);
|
| 32 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 33 |
+
--color-secondary: var(--secondary);
|
| 34 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 35 |
+
--color-primary: var(--primary);
|
| 36 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 37 |
+
--color-popover: var(--popover);
|
| 38 |
+
--color-card-foreground: var(--card-foreground);
|
| 39 |
+
--color-card: var(--card);
|
| 40 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 41 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 42 |
+
--radius-lg: var(--radius);
|
| 43 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
:root {
|
| 47 |
+
--radius: 0.625rem;
|
| 48 |
+
--background: oklch(1 0 0);
|
| 49 |
+
--foreground: oklch(0.145 0 0);
|
| 50 |
+
--card: oklch(1 0 0);
|
| 51 |
+
--card-foreground: oklch(0.145 0 0);
|
| 52 |
+
--popover: oklch(1 0 0);
|
| 53 |
+
--popover-foreground: oklch(0.145 0 0);
|
| 54 |
+
--primary: oklch(0.205 0 0);
|
| 55 |
+
--primary-foreground: oklch(0.985 0 0);
|
| 56 |
+
--secondary: oklch(0.97 0 0);
|
| 57 |
+
--secondary-foreground: oklch(0.205 0 0);
|
| 58 |
+
--muted: oklch(0.97 0 0);
|
| 59 |
+
--muted-foreground: oklch(0.556 0 0);
|
| 60 |
+
--accent: oklch(0.97 0 0);
|
| 61 |
+
--accent-foreground: oklch(0.205 0 0);
|
| 62 |
+
--destructive: oklch(0.577 0.245 27.325);
|
| 63 |
+
--border: oklch(0.922 0 0);
|
| 64 |
+
--input: oklch(0.922 0 0);
|
| 65 |
+
--ring: oklch(0.708 0 0);
|
| 66 |
+
--chart-1: oklch(0.646 0.222 41.116);
|
| 67 |
+
--chart-2: oklch(0.6 0.118 184.704);
|
| 68 |
+
--chart-3: oklch(0.398 0.07 227.392);
|
| 69 |
+
--chart-4: oklch(0.828 0.189 84.429);
|
| 70 |
+
--chart-5: oklch(0.769 0.188 70.08);
|
| 71 |
+
--sidebar: oklch(0.985 0 0);
|
| 72 |
+
--sidebar-foreground: oklch(0.145 0 0);
|
| 73 |
+
--sidebar-primary: oklch(0.205 0 0);
|
| 74 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 75 |
+
--sidebar-accent: oklch(0.97 0 0);
|
| 76 |
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
| 77 |
+
--sidebar-border: oklch(0.922 0 0);
|
| 78 |
+
--sidebar-ring: oklch(0.708 0 0);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.dark {
|
| 82 |
+
--background: oklch(0.145 0 0);
|
| 83 |
+
--foreground: oklch(0.985 0 0);
|
| 84 |
+
--card: oklch(0.205 0 0);
|
| 85 |
+
--card-foreground: oklch(0.985 0 0);
|
| 86 |
+
--popover: oklch(0.205 0 0);
|
| 87 |
+
--popover-foreground: oklch(0.985 0 0);
|
| 88 |
+
--primary: oklch(0.922 0 0);
|
| 89 |
+
--primary-foreground: oklch(0.205 0 0);
|
| 90 |
+
--secondary: oklch(0.269 0 0);
|
| 91 |
+
--secondary-foreground: oklch(0.985 0 0);
|
| 92 |
+
--muted: oklch(0.269 0 0);
|
| 93 |
+
--muted-foreground: oklch(0.708 0 0);
|
| 94 |
+
--accent: oklch(0.269 0 0);
|
| 95 |
+
--accent-foreground: oklch(0.985 0 0);
|
| 96 |
+
--destructive: oklch(0.704 0.191 22.216);
|
| 97 |
+
--border: oklch(1 0 0 / 10%);
|
| 98 |
+
--input: oklch(1 0 0 / 15%);
|
| 99 |
+
--ring: oklch(0.556 0 0);
|
| 100 |
+
--chart-1: oklch(0.488 0.243 264.376);
|
| 101 |
+
--chart-2: oklch(0.696 0.17 162.48);
|
| 102 |
+
--chart-3: oklch(0.769 0.188 70.08);
|
| 103 |
+
--chart-4: oklch(0.627 0.265 303.9);
|
| 104 |
+
--chart-5: oklch(0.645 0.246 16.439);
|
| 105 |
+
--sidebar: oklch(0.205 0 0);
|
| 106 |
+
--sidebar-foreground: oklch(0.985 0 0);
|
| 107 |
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
| 108 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 109 |
+
--sidebar-accent: oklch(0.269 0 0);
|
| 110 |
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
| 111 |
+
--sidebar-border: oklch(1 0 0 / 10%);
|
| 112 |
+
--sidebar-ring: oklch(0.556 0 0);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
@layer base {
|
| 116 |
+
* {
|
| 117 |
+
@apply border-border outline-ring/50;
|
| 118 |
+
}
|
| 119 |
+
body {
|
| 120 |
+
@apply bg-background text-foreground;
|
| 121 |
+
}
|
| 122 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { Toaster } from "@/components/ui/toaster";
|
| 5 |
+
|
| 6 |
+
const geistSans = Geist({
|
| 7 |
+
variable: "--font-geist-sans",
|
| 8 |
+
subsets: ["latin"],
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
const geistMono = Geist_Mono({
|
| 12 |
+
variable: "--font-geist-mono",
|
| 13 |
+
subsets: ["latin"],
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
export const metadata: Metadata = {
|
| 17 |
+
title: "Sofía Cloud - Multiagente AI",
|
| 18 |
+
description: "Sistema multiagente para análisis de código, gestión de repositorios y desarrollo asistido por IA.",
|
| 19 |
+
keywords: ["Sofía", "AI", "Next.js", "TypeScript", "Tailwind CSS", "shadcn/ui", "Agentes", "Código"],
|
| 20 |
+
authors: [{ name: "Sofía Cloud Team" }],
|
| 21 |
+
icons: {
|
| 22 |
+
icon: "/logo.svg",
|
| 23 |
+
},
|
| 24 |
+
openGraph: {
|
| 25 |
+
title: "Sofía Cloud - Multiagente AI",
|
| 26 |
+
description: "Sistema multiagente para análisis de código y desarrollo con IA",
|
| 27 |
+
type: "website",
|
| 28 |
+
},
|
| 29 |
+
twitter: {
|
| 30 |
+
card: "summary_large_image",
|
| 31 |
+
title: "Sofía Cloud - Multiagente AI",
|
| 32 |
+
description: "Sistema multiagente para análisis de código y desarrollo con IA",
|
| 33 |
+
},
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export default function RootLayout({
|
| 37 |
+
children,
|
| 38 |
+
}: Readonly<{
|
| 39 |
+
children: React.ReactNode;
|
| 40 |
+
}>) {
|
| 41 |
+
return (
|
| 42 |
+
<html lang="en" suppressHydrationWarning>
|
| 43 |
+
<body
|
| 44 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
| 45 |
+
>
|
| 46 |
+
{children}
|
| 47 |
+
<Toaster />
|
| 48 |
+
</body>
|
| 49 |
+
</html>
|
| 50 |
+
);
|
| 51 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 5 |
+
import {
|
| 6 |
+
Menu, X, Bot, Wand2, ImageIcon, Video, Users, FolderGit2, Shield,
|
| 7 |
+
RefreshCw, DollarSign, Clock, Film, Zap, TrendingUp, Calendar,
|
| 8 |
+
Play, Pause, Trash2, Plus, Send, Copy, Eye, Settings, ChevronRight,
|
| 9 |
+
CheckCircle2, AlertCircle, ExternalLink, CreditCard, Target, Sparkles,
|
| 10 |
+
Heart, PawPrint, Star, Users2, Lightbulb, Flame, Rocket
|
| 11 |
+
} from "lucide-react";
|
| 12 |
+
|
| 13 |
+
import { Button } from "@/components/ui/button";
|
| 14 |
+
import { Input } from "@/components/ui/input";
|
| 15 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 16 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 17 |
+
import { Badge } from "@/components/ui/badge";
|
| 18 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 19 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 20 |
+
import { Label } from "@/components/ui/label";
|
| 21 |
+
import { Switch } from "@/components/ui/switch";
|
| 22 |
+
import { toast } from "sonner";
|
| 23 |
+
|
| 24 |
+
// Types
|
| 25 |
+
interface Content { id: string; type: string; title: string; status: string; platform: string; createdAt: string; }
|
| 26 |
+
interface Character { id: string; name: string; description: string | null; referenceImage: string | null; }
|
| 27 |
+
interface Platform { id: string; name: string; type: string; isActive: boolean; isVerified: boolean; }
|
| 28 |
+
interface Post { id: string; title: string | null; type: string; status: string; scheduledAt: string | null; }
|
| 29 |
+
interface Story { id: string; title: string; genre: string; status: string; totalEpisodes: number; }
|
| 30 |
+
interface Automation { id: string; name: string; type: string; isActive: boolean; runCount: number; }
|
| 31 |
+
interface Pet { id: string; name: string; type: string; breed: string | null; personality: string | null; referenceImage: string | null; }
|
| 32 |
+
interface Influencer { name: string; handle: string; platform: string; followers: number; engagement: number; niche: string; petCompanion: boolean; petType?: string; keyLessons: string[]; }
|
| 33 |
+
|
| 34 |
+
// API Helper
|
| 35 |
+
async function apiFetch(endpoint: string, options: RequestInit = {}) {
|
| 36 |
+
const response = await fetch(`/api${endpoint}`, {
|
| 37 |
+
headers: { "Content-Type": "application/json", ...options.headers },
|
| 38 |
+
...options,
|
| 39 |
+
});
|
| 40 |
+
return response.json();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export default function Dashboard() {
|
| 44 |
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 45 |
+
const [activeTab, setActiveTab] = useState("prompt-engineer");
|
| 46 |
+
const [loading, setLoading] = useState(false);
|
| 47 |
+
|
| 48 |
+
// Prompt Engineer
|
| 49 |
+
const [userPrompt, setUserPrompt] = useState("");
|
| 50 |
+
const [promptType, setPromptType] = useState("image");
|
| 51 |
+
const [targetPlatform, setTargetPlatform] = useState("general");
|
| 52 |
+
const [optimizedPrompt, setOptimizedPrompt] = useState<Record<string, unknown> | null>(null);
|
| 53 |
+
const [promptLoading, setPromptLoading] = useState(false);
|
| 54 |
+
|
| 55 |
+
// Image Generation
|
| 56 |
+
const [imagePrompt, setImagePrompt] = useState("");
|
| 57 |
+
const [imageStyle, setImageStyle] = useState("realistic");
|
| 58 |
+
const [imagePlatform, setImagePlatform] = useState("general");
|
| 59 |
+
const [imageLoading, setImageLoading] = useState(false);
|
| 60 |
+
|
| 61 |
+
// Monetization
|
| 62 |
+
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
| 63 |
+
const [selectedMonetizationPlatform, setSelectedMonetizationPlatform] = useState("");
|
| 64 |
+
|
| 65 |
+
// Posts
|
| 66 |
+
const [posts, setPosts] = useState<Post[]>([]);
|
| 67 |
+
const [postTitle, setPostTitle] = useState("");
|
| 68 |
+
const [postType, setPostType] = useState("reel");
|
| 69 |
+
const [postCaption, setPostCaption] = useState("");
|
| 70 |
+
const [scheduledTime, setScheduledTime] = useState("");
|
| 71 |
+
|
| 72 |
+
// Stories
|
| 73 |
+
const [stories, setStories] = useState<Story[]>([]);
|
| 74 |
+
const [storyPrompt, setStoryPrompt] = useState("");
|
| 75 |
+
const [storyGenre, setStoryGenre] = useState("lifestyle");
|
| 76 |
+
const [storyEpisodes, setStoryEpisodes] = useState(7);
|
| 77 |
+
const [storyLoading, setStoryLoading] = useState(false);
|
| 78 |
+
|
| 79 |
+
// Automation
|
| 80 |
+
const [automations, setAutomations] = useState<Automation[]>([]);
|
| 81 |
+
const [automationName, setAutomationName] = useState("");
|
| 82 |
+
const [automationType, setAutomationType] = useState("content_generation");
|
| 83 |
+
|
| 84 |
+
// Content & Characters
|
| 85 |
+
const [contents, setContents] = useState<Content[]>([]);
|
| 86 |
+
const [characters, setCharacters] = useState<Character[]>([]);
|
| 87 |
+
|
| 88 |
+
// Pets
|
| 89 |
+
const [pets, setPets] = useState<Pet[]>([]);
|
| 90 |
+
const [petName, setPetName] = useState("");
|
| 91 |
+
const [petType, setPetType] = useState("dog");
|
| 92 |
+
const [petBreed, setPetBreed] = useState("");
|
| 93 |
+
const [petPersonality, setPetPersonality] = useState("");
|
| 94 |
+
const [includePetInContent, setIncludePetInContent] = useState(false);
|
| 95 |
+
const [petLoading, setPetLoading] = useState(false);
|
| 96 |
+
|
| 97 |
+
// Influencers
|
| 98 |
+
const [influencers, setInfluencers] = useState<Influencer[]>([]);
|
| 99 |
+
const [influencerNiche, setInfluencerNiche] = useState("");
|
| 100 |
+
const [influencerPlatform, setInfluencerPlatform] = useState("");
|
| 101 |
+
const [influencerWithPets, setInfluencerWithPets] = useState(false);
|
| 102 |
+
const [influencerLoading, setInfluencerLoading] = useState(false);
|
| 103 |
+
const [influencerAnalysis, setInfluencerAnalysis] = useState<Record<string, unknown> | null>(null);
|
| 104 |
+
const [characterConcept, setCharacterConcept] = useState<Record<string, unknown> | null>(null);
|
| 105 |
+
|
| 106 |
+
// Trends
|
| 107 |
+
const [trends, setTrends] = useState<Record<string, unknown>[]>([]);
|
| 108 |
+
const [viralStrategies, setViralStrategies] = useState<Record<string, unknown>[]>([]);
|
| 109 |
+
const [contentIdeas, setContentIdeas] = useState<Record<string, unknown>[]>([]);
|
| 110 |
+
const [trendAnalysis, setTrendAnalysis] = useState<Record<string, unknown> | null>(null);
|
| 111 |
+
const [trendLoading, setTrendLoading] = useState(false);
|
| 112 |
+
|
| 113 |
+
// Stats
|
| 114 |
+
const [stats, setStats] = useState({ images: 0, videos: 0, stories: 0, automations: 0, pets: 0 });
|
| 115 |
+
|
| 116 |
+
// Load data
|
| 117 |
+
const loadData = useCallback(async () => {
|
| 118 |
+
setLoading(true);
|
| 119 |
+
try {
|
| 120 |
+
const [contentRes, platformsRes, postsRes, storiesRes, automationRes, petsRes] = await Promise.all([
|
| 121 |
+
apiFetch("/content"),
|
| 122 |
+
apiFetch("/monetization"),
|
| 123 |
+
apiFetch("/posts"),
|
| 124 |
+
apiFetch("/storytelling"),
|
| 125 |
+
apiFetch("/automation"),
|
| 126 |
+
apiFetch("/pets"),
|
| 127 |
+
]);
|
| 128 |
+
|
| 129 |
+
if (contentRes.success) {
|
| 130 |
+
setContents(contentRes.contents);
|
| 131 |
+
setStats({
|
| 132 |
+
images: contentRes.stats?.images || 0,
|
| 133 |
+
videos: contentRes.stats?.videos || 0,
|
| 134 |
+
stories: storiesRes?.total || 0,
|
| 135 |
+
automations: automationRes?.stats?.total || 0,
|
| 136 |
+
pets: petsRes?.total || 0,
|
| 137 |
+
});
|
| 138 |
+
}
|
| 139 |
+
if (platformsRes.success) setPlatforms(platformsRes.userPlatforms);
|
| 140 |
+
if (postsRes.success) setPosts(postsRes.posts);
|
| 141 |
+
if (storiesRes.success) setStories(storiesRes.stories);
|
| 142 |
+
if (automationRes.success) setAutomations(automationRes.automations);
|
| 143 |
+
if (petsRes.success) setPets(petsRes.pets);
|
| 144 |
+
} catch {
|
| 145 |
+
toast.error("Error al cargar datos");
|
| 146 |
+
} finally {
|
| 147 |
+
setLoading(false);
|
| 148 |
+
}
|
| 149 |
+
}, []);
|
| 150 |
+
|
| 151 |
+
useEffect(() => { loadData(); }, [loadData]);
|
| 152 |
+
|
| 153 |
+
// Actions
|
| 154 |
+
const handleOptimizePrompt = async () => {
|
| 155 |
+
if (!userPrompt.trim()) { toast.error("Escribe un prompt"); return; }
|
| 156 |
+
setPromptLoading(true);
|
| 157 |
+
try {
|
| 158 |
+
const result = await apiFetch("/prompt-engineer", {
|
| 159 |
+
method: "POST",
|
| 160 |
+
body: JSON.stringify({ prompt: userPrompt, type: promptType, platform: targetPlatform }),
|
| 161 |
+
});
|
| 162 |
+
if (result.success) {
|
| 163 |
+
setOptimizedPrompt(result);
|
| 164 |
+
toast.success("Prompt optimizado");
|
| 165 |
+
} else toast.error(result.error);
|
| 166 |
+
} catch { toast.error("Error"); }
|
| 167 |
+
finally { setPromptLoading(false); }
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
+
const handleGenerateImage = async () => {
|
| 171 |
+
if (!userPrompt.trim()) { toast.error("Escribe un prompt"); return; }
|
| 172 |
+
setImageLoading(true);
|
| 173 |
+
try {
|
| 174 |
+
const result = await apiFetch("/generate/image", {
|
| 175 |
+
method: "POST",
|
| 176 |
+
body: JSON.stringify({
|
| 177 |
+
prompt: userPrompt,
|
| 178 |
+
optimizedPrompt: optimizedPrompt?.optimizedPrompt,
|
| 179 |
+
platform: imagePlatform,
|
| 180 |
+
style: imageStyle,
|
| 181 |
+
includePet: includePetInContent,
|
| 182 |
+
petId: pets[0]?.id
|
| 183 |
+
}),
|
| 184 |
+
});
|
| 185 |
+
if (result.success) {
|
| 186 |
+
toast.success("Imagen generada");
|
| 187 |
+
loadData();
|
| 188 |
+
} else toast.error(result.error);
|
| 189 |
+
} catch { toast.error("Error"); }
|
| 190 |
+
finally { setImageLoading(false); }
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
const handleCreateStory = async () => {
|
| 194 |
+
if (!storyPrompt.trim()) { toast.error("Describe tu historia"); return; }
|
| 195 |
+
setStoryLoading(true);
|
| 196 |
+
try {
|
| 197 |
+
const result = await apiFetch("/storytelling", {
|
| 198 |
+
method: "POST",
|
| 199 |
+
body: JSON.stringify({
|
| 200 |
+
prompt: storyPrompt,
|
| 201 |
+
genre: storyGenre,
|
| 202 |
+
totalEpisodes: storyEpisodes,
|
| 203 |
+
}),
|
| 204 |
+
});
|
| 205 |
+
if (result.success) {
|
| 206 |
+
toast.success(`Historia "${result.story?.title}" creada`);
|
| 207 |
+
setStoryPrompt("");
|
| 208 |
+
loadData();
|
| 209 |
+
} else toast.error(result.error);
|
| 210 |
+
} catch { toast.error("Error"); }
|
| 211 |
+
finally { setStoryLoading(false); }
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const handleCreatePost = async () => {
|
| 215 |
+
if (!postTitle.trim()) { toast.error("Añade un título"); return; }
|
| 216 |
+
try {
|
| 217 |
+
const result = await apiFetch("/posts", {
|
| 218 |
+
method: "POST",
|
| 219 |
+
body: JSON.stringify({
|
| 220 |
+
title: postTitle,
|
| 221 |
+
type: postType,
|
| 222 |
+
caption: postCaption,
|
| 223 |
+
scheduledAt: scheduledTime || null,
|
| 224 |
+
autoGenerateCaption: true,
|
| 225 |
+
}),
|
| 226 |
+
});
|
| 227 |
+
if (result.success) {
|
| 228 |
+
toast.success(scheduledTime ? "Post programado" : "Post creado");
|
| 229 |
+
setPostTitle(""); setPostCaption(""); setScheduledTime("");
|
| 230 |
+
loadData();
|
| 231 |
+
} else toast.error(result.error);
|
| 232 |
+
} catch { toast.error("Error"); }
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
const handleCreateAutomation = async () => {
|
| 236 |
+
if (!automationName.trim()) { toast.error("Dale un nombre"); return; }
|
| 237 |
+
try {
|
| 238 |
+
const result = await apiFetch("/automation", {
|
| 239 |
+
method: "POST",
|
| 240 |
+
body: JSON.stringify({
|
| 241 |
+
name: automationName,
|
| 242 |
+
type: automationType,
|
| 243 |
+
trigger: "schedule",
|
| 244 |
+
triggerConfig: { schedule: "daily:09:00" },
|
| 245 |
+
actions: [{ type: "generate_content", prompt: "Genera contenido de lifestyle" }],
|
| 246 |
+
}),
|
| 247 |
+
});
|
| 248 |
+
if (result.success) {
|
| 249 |
+
toast.success("Automatización creada");
|
| 250 |
+
setAutomationName("");
|
| 251 |
+
loadData();
|
| 252 |
+
} else toast.error(result.error);
|
| 253 |
+
} catch { toast.error("Error"); }
|
| 254 |
+
};
|
| 255 |
+
|
| 256 |
+
const handleToggleAutomation = async (id: string, currentStatus: boolean) => {
|
| 257 |
+
try {
|
| 258 |
+
await apiFetch("/automation", {
|
| 259 |
+
method: "PUT",
|
| 260 |
+
body: JSON.stringify({ id, isActive: !currentStatus }),
|
| 261 |
+
});
|
| 262 |
+
loadData();
|
| 263 |
+
} catch { toast.error("Error"); }
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
const handleCreatePet = async () => {
|
| 267 |
+
if (!petName.trim()) { toast.error("Dale un nombre a tu mascota"); return; }
|
| 268 |
+
setPetLoading(true);
|
| 269 |
+
try {
|
| 270 |
+
const result = await apiFetch("/pets", {
|
| 271 |
+
method: "POST",
|
| 272 |
+
body: JSON.stringify({
|
| 273 |
+
name: petName,
|
| 274 |
+
type: petType,
|
| 275 |
+
breed: petBreed || undefined,
|
| 276 |
+
personality: petPersonality || undefined,
|
| 277 |
+
generateReference: true
|
| 278 |
+
}),
|
| 279 |
+
});
|
| 280 |
+
if (result.success) {
|
| 281 |
+
toast.success(`Mascota "${petName}" creada (+${result.engagementBoost}% engagement)`);
|
| 282 |
+
setPetName(""); setPetBreed(""); setPetPersonality("");
|
| 283 |
+
loadData();
|
| 284 |
+
} else toast.error(result.error);
|
| 285 |
+
} catch { toast.error("Error"); }
|
| 286 |
+
finally { setPetLoading(false); }
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
const handleAnalyzeInfluencers = async () => {
|
| 290 |
+
setInfluencerLoading(true);
|
| 291 |
+
try {
|
| 292 |
+
const result = await apiFetch("/influencers", {
|
| 293 |
+
method: "POST",
|
| 294 |
+
body: JSON.stringify({
|
| 295 |
+
targetNiche: influencerNiche || "lifestyle",
|
| 296 |
+
targetPlatform: influencerPlatform || undefined,
|
| 297 |
+
includePets: influencerWithPets
|
| 298 |
+
}),
|
| 299 |
+
});
|
| 300 |
+
if (result.success) {
|
| 301 |
+
setInfluencers(result.referenceInfluencers || []);
|
| 302 |
+
setInfluencerAnalysis(result.analysis);
|
| 303 |
+
setCharacterConcept(result.characterConcept);
|
| 304 |
+
toast.success("Análisis completado");
|
| 305 |
+
} else toast.error(result.error);
|
| 306 |
+
} catch { toast.error("Error"); }
|
| 307 |
+
finally { setInfluencerLoading(false); }
|
| 308 |
+
};
|
| 309 |
+
|
| 310 |
+
const handleAnalyzeTrends = async () => {
|
| 311 |
+
setTrendLoading(true);
|
| 312 |
+
try {
|
| 313 |
+
const result = await apiFetch("/trends", {
|
| 314 |
+
method: "POST",
|
| 315 |
+
body: JSON.stringify({
|
| 316 |
+
niche: influencerNiche || "lifestyle",
|
| 317 |
+
platform: influencerPlatform || undefined,
|
| 318 |
+
includePets: includePetInContent,
|
| 319 |
+
daysToViral: 14
|
| 320 |
+
}),
|
| 321 |
+
});
|
| 322 |
+
if (result.success) {
|
| 323 |
+
setTrendAnalysis(result.analysis);
|
| 324 |
+
toast.success("Análisis de tendencias completado");
|
| 325 |
+
} else toast.error(result.error);
|
| 326 |
+
} catch { toast.error("Error"); }
|
| 327 |
+
finally { setTrendLoading(false); }
|
| 328 |
+
};
|
| 329 |
+
|
| 330 |
+
const loadTrends = async () => {
|
| 331 |
+
try {
|
| 332 |
+
const result = await apiFetch(`/trends?includePets=${includePetInContent}`);
|
| 333 |
+
if (result.success) {
|
| 334 |
+
setTrends(result.trends);
|
| 335 |
+
setViralStrategies(result.viralStrategies);
|
| 336 |
+
setContentIdeas(result.contentIdeas);
|
| 337 |
+
}
|
| 338 |
+
} catch { toast.error("Error cargando tendencias"); }
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
useEffect(() => {
|
| 342 |
+
if (activeTab === "trends") loadTrends();
|
| 343 |
+
}, [activeTab, includePetInContent]);
|
| 344 |
+
|
| 345 |
+
const copyToClipboard = (text: string) => {
|
| 346 |
+
navigator.clipboard.writeText(text);
|
| 347 |
+
toast.success("Copiado");
|
| 348 |
+
};
|
| 349 |
+
|
| 350 |
+
const getStatusColor = (status: string) => {
|
| 351 |
+
const colors: Record<string, string> = {
|
| 352 |
+
completed: "bg-green-500/10 text-green-500",
|
| 353 |
+
published: "bg-green-500/10 text-green-500",
|
| 354 |
+
scheduled: "bg-blue-500/10 text-blue-500",
|
| 355 |
+
draft: "bg-slate-500/10 text-slate-400",
|
| 356 |
+
pending: "bg-yellow-500/10 text-yellow-500",
|
| 357 |
+
active: "bg-green-500/10 text-green-500",
|
| 358 |
+
};
|
| 359 |
+
return colors[status] || "bg-slate-500/10 text-slate-400";
|
| 360 |
+
};
|
| 361 |
+
|
| 362 |
+
const petTypes = [
|
| 363 |
+
{ value: "dog", label: "🐕 Perro" },
|
| 364 |
+
{ value: "cat", label: "🐱 Gato" },
|
| 365 |
+
{ value: "bird", label: "🐦 Pájaro" },
|
| 366 |
+
{ value: "rabbit", label: "🐰 Conejo" },
|
| 367 |
+
{ value: "hamster", label: "🐹 Hámster" }
|
| 368 |
+
];
|
| 369 |
+
|
| 370 |
+
return (
|
| 371 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-white">
|
| 372 |
+
{/* Sidebar */}
|
| 373 |
+
<AnimatePresence mode="wait">
|
| 374 |
+
{sidebarOpen && (
|
| 375 |
+
<motion.aside
|
| 376 |
+
initial={{ x: -280 }} animate={{ x: 0 }} exit={{ x: -280 }}
|
| 377 |
+
className="fixed left-0 top-0 h-full w-64 bg-slate-900/90 backdrop-blur-xl border-r border-slate-800 z-50"
|
| 378 |
+
>
|
| 379 |
+
<div className="p-5">
|
| 380 |
+
<div className="flex items-center gap-3 mb-6">
|
| 381 |
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
|
| 382 |
+
<Bot className="h-5 w-5" />
|
| 383 |
+
</div>
|
| 384 |
+
<div>
|
| 385 |
+
<h1 className="text-lg font-bold bg-gradient-to-r from-violet-400 to-purple-400 bg-clip-text text-transparent">Sofía Cloud</h1>
|
| 386 |
+
<p className="text-xs text-slate-400">Monetización Pro</p>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
<nav className="space-y-1">
|
| 391 |
+
{[
|
| 392 |
+
{ id: "prompt-engineer", icon: Wand2, label: "Ingeniero IA" },
|
| 393 |
+
{ id: "images", icon: ImageIcon, label: "Imágenes" },
|
| 394 |
+
{ id: "videos", icon: Video, label: "Videos" },
|
| 395 |
+
{ id: "monetization", icon: DollarSign, label: "Monetización" },
|
| 396 |
+
{ id: "posts", icon: Calendar, label: "Publicaciones" },
|
| 397 |
+
{ id: "storytelling", icon: Film, label: "Storytelling" },
|
| 398 |
+
{ id: "automation", icon: Zap, label: "Automatización" },
|
| 399 |
+
{ id: "influencers", icon: Users2, label: "Influencers IA" },
|
| 400 |
+
{ id: "pets", icon: PawPrint, label: "Mascotas" },
|
| 401 |
+
{ id: "trends", icon: TrendingUp, label: "Tendencias" },
|
| 402 |
+
{ id: "content", icon: FolderGit2, label: "Contenido" },
|
| 403 |
+
].map((item) => (
|
| 404 |
+
<button key={item.id} onClick={() => setActiveTab(item.id)}
|
| 405 |
+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all text-sm ${
|
| 406 |
+
activeTab === item.id ? "bg-violet-500/20 text-violet-400 border border-violet-500/30" : "text-slate-400 hover:bg-slate-800"}`}>
|
| 407 |
+
<item.icon className="h-4 w-4" /><span>{item.label}</span>
|
| 408 |
+
</button>
|
| 409 |
+
))}
|
| 410 |
+
</nav>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-800">
|
| 414 |
+
<div className="grid grid-cols-2 gap-2 text-center text-xs">
|
| 415 |
+
<div><p className="text-xl font-bold text-violet-400">{stats.images}</p><p className="text-slate-500">Imágenes</p></div>
|
| 416 |
+
<div><p className="text-xl font-bold text-blue-400">{stats.videos}</p><p className="text-slate-500">Videos</p></div>
|
| 417 |
+
<div><p className="text-xl font-bold text-green-400">{stats.stories}</p><p className="text-slate-500">Historias</p></div>
|
| 418 |
+
<div><p className="text-xl font-bold text-amber-400">{stats.pets}</p><p className="text-slate-500">Mascotas</p></div>
|
| 419 |
+
</div>
|
| 420 |
+
</div>
|
| 421 |
+
</motion.aside>
|
| 422 |
+
)}
|
| 423 |
+
</AnimatePresence>
|
| 424 |
+
|
| 425 |
+
{/* Main */}
|
| 426 |
+
<div className={`transition-all duration-300 ${sidebarOpen ? "ml-64" : "ml-0"}`}>
|
| 427 |
+
<header className="sticky top-0 z-40 bg-slate-950/80 backdrop-blur-xl border-b border-slate-800">
|
| 428 |
+
<div className="flex items-center justify-between px-6 py-3">
|
| 429 |
+
<div className="flex items-center gap-3">
|
| 430 |
+
<Button variant="ghost" size="icon" onClick={() => setSidebarOpen(!sidebarOpen)} className="text-slate-400">
|
| 431 |
+
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
| 432 |
+
</Button>
|
| 433 |
+
<h2 className="text-lg font-semibold capitalize">{activeTab.replace("-", " ")}</h2>
|
| 434 |
+
</div>
|
| 435 |
+
<Button variant="outline" size="sm" onClick={loadData} disabled={loading} className="border-slate-700">
|
| 436 |
+
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />Actualizar
|
| 437 |
+
</Button>
|
| 438 |
+
</div>
|
| 439 |
+
</header>
|
| 440 |
+
|
| 441 |
+
<main className="p-6">
|
| 442 |
+
{/* Prompt Engineer */}
|
| 443 |
+
{activeTab === "prompt-engineer" && (
|
| 444 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 445 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 446 |
+
<CardHeader>
|
| 447 |
+
<CardTitle className="flex items-center gap-2"><Wand2 className="h-5 w-5 text-violet-400" />Ingeniero de Prompts</CardTitle>
|
| 448 |
+
<CardDescription>Describe en lenguaje natural lo que quieres crear</CardDescription>
|
| 449 |
+
</CardHeader>
|
| 450 |
+
<CardContent className="space-y-4">
|
| 451 |
+
<Textarea placeholder="Ej: Quiero fotos de una mujer rubia en la playa para OnlyFans..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700 min-h-28" />
|
| 452 |
+
<div className="grid grid-cols-2 gap-4">
|
| 453 |
+
<div>
|
| 454 |
+
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 455 |
+
<Select value={promptType} onValueChange={setPromptType}>
|
| 456 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 457 |
+
<SelectContent>
|
| 458 |
+
<SelectItem value="image">Imagen</SelectItem>
|
| 459 |
+
<SelectItem value="video">Video</SelectItem>
|
| 460 |
+
<SelectItem value="reel">Reel</SelectItem>
|
| 461 |
+
<SelectItem value="carousel">Carrusel</SelectItem>
|
| 462 |
+
</SelectContent>
|
| 463 |
+
</Select>
|
| 464 |
+
</div>
|
| 465 |
+
<div>
|
| 466 |
+
<Label className="text-xs text-slate-400">Plataforma</Label>
|
| 467 |
+
<Select value={targetPlatform} onValueChange={setTargetPlatform}>
|
| 468 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 469 |
+
<SelectContent>
|
| 470 |
+
<SelectItem value="general">General</SelectItem>
|
| 471 |
+
<SelectItem value="onlyfans">OnlyFans</SelectItem>
|
| 472 |
+
<SelectItem value="patreon">Patreon</SelectItem>
|
| 473 |
+
<SelectItem value="instagram">Instagram</SelectItem>
|
| 474 |
+
<SelectItem value="tiktok">TikTok</SelectItem>
|
| 475 |
+
<SelectItem value="youtube">YouTube</SelectItem>
|
| 476 |
+
</SelectContent>
|
| 477 |
+
</Select>
|
| 478 |
+
</div>
|
| 479 |
+
</div>
|
| 480 |
+
{pets.length > 0 && (
|
| 481 |
+
<div className="flex items-center gap-2">
|
| 482 |
+
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 483 |
+
<Label className="text-sm text-slate-400">Incluir mascota en el contenido (+35% engagement)</Label>
|
| 484 |
+
</div>
|
| 485 |
+
)}
|
| 486 |
+
<Button onClick={handleOptimizePrompt} disabled={promptLoading} className="w-full bg-violet-600 hover:bg-violet-700">
|
| 487 |
+
{promptLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Wand2 className="h-4 w-4 mr-2" />}Optimizar
|
| 488 |
+
</Button>
|
| 489 |
+
</CardContent>
|
| 490 |
+
</Card>
|
| 491 |
+
|
| 492 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 493 |
+
<CardHeader><CardTitle>Prompt Optimizado</CardTitle></CardHeader>
|
| 494 |
+
<CardContent>
|
| 495 |
+
{optimizedPrompt ? (
|
| 496 |
+
<div className="space-y-3">
|
| 497 |
+
<div className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 498 |
+
<div className="flex justify-between mb-2">
|
| 499 |
+
<Badge className="bg-violet-500/20 text-violet-400">{String(optimizedPrompt.type)}</Badge>
|
| 500 |
+
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(String(optimizedPrompt.optimizedPrompt))}><Copy className="h-4 w-4" /></Button>
|
| 501 |
+
</div>
|
| 502 |
+
<p className="text-sm whitespace-pre-wrap">{String(optimizedPrompt.optimizedPrompt)}</p>
|
| 503 |
+
</div>
|
| 504 |
+
<div className="flex gap-2">
|
| 505 |
+
<Button onClick={handleGenerateImage} disabled={imageLoading} className="flex-1 bg-green-600 hover:bg-green-700">
|
| 506 |
+
<ImageIcon className="h-4 w-4 mr-2" />Generar Imagen
|
| 507 |
+
</Button>
|
| 508 |
+
<Button onClick={() => setActiveTab("posts")} className="flex-1 bg-blue-600 hover:bg-blue-700">
|
| 509 |
+
<Calendar className="h-4 w-4 mr-2" />Crear Post
|
| 510 |
+
</Button>
|
| 511 |
+
</div>
|
| 512 |
+
</div>
|
| 513 |
+
) : (
|
| 514 |
+
<div className="text-center py-12 text-slate-400">
|
| 515 |
+
<Wand2 className="h-12 w-12 mx-auto mb-3 opacity-50" /><p>El prompt optimizado aparecerá aquí</p>
|
| 516 |
+
</div>
|
| 517 |
+
)}
|
| 518 |
+
</CardContent>
|
| 519 |
+
</Card>
|
| 520 |
+
</div>
|
| 521 |
+
)}
|
| 522 |
+
|
| 523 |
+
{/* Images */}
|
| 524 |
+
{activeTab === "images" && (
|
| 525 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 526 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><ImageIcon className="h-5 w-5 text-green-400" />Generar Imágenes</CardTitle></CardHeader>
|
| 527 |
+
<CardContent className="space-y-4">
|
| 528 |
+
<Textarea placeholder="Describe la imagen..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 529 |
+
<div className="grid grid-cols-3 gap-4">
|
| 530 |
+
<div>
|
| 531 |
+
<Label className="text-xs text-slate-400">Estilo</Label>
|
| 532 |
+
<Select value={imageStyle} onValueChange={setImageStyle}>
|
| 533 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 534 |
+
<SelectContent>
|
| 535 |
+
<SelectItem value="realistic">Realista</SelectItem>
|
| 536 |
+
<SelectItem value="anime">Anime</SelectItem>
|
| 537 |
+
<SelectItem value="artistic">Artístico</SelectItem>
|
| 538 |
+
</SelectContent>
|
| 539 |
+
</Select>
|
| 540 |
+
</div>
|
| 541 |
+
<div>
|
| 542 |
+
<Label className="text-xs text-slate-400">Plataforma</Label>
|
| 543 |
+
<Select value={imagePlatform} onValueChange={setImagePlatform}>
|
| 544 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 545 |
+
<SelectContent>
|
| 546 |
+
<SelectItem value="general">General</SelectItem>
|
| 547 |
+
<SelectItem value="onlyfans">OnlyFans</SelectItem>
|
| 548 |
+
<SelectItem value="instagram">Instagram</SelectItem>
|
| 549 |
+
</SelectContent>
|
| 550 |
+
</Select>
|
| 551 |
+
</div>
|
| 552 |
+
</div>
|
| 553 |
+
{pets.length > 0 && (
|
| 554 |
+
<div className="flex items-center gap-2">
|
| 555 |
+
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 556 |
+
<Label className="text-sm text-slate-400">Incluir mascota ({pets[0]?.name})</Label>
|
| 557 |
+
</div>
|
| 558 |
+
)}
|
| 559 |
+
<Button onClick={handleGenerateImage} disabled={imageLoading} className="w-full bg-green-600 hover:bg-green-700">
|
| 560 |
+
{imageLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <ImageIcon className="h-4 w-4 mr-2" />}Generar
|
| 561 |
+
</Button>
|
| 562 |
+
</CardContent>
|
| 563 |
+
</Card>
|
| 564 |
+
)}
|
| 565 |
+
|
| 566 |
+
{/* Monetization */}
|
| 567 |
+
{activeTab === "monetization" && (
|
| 568 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 569 |
+
{[
|
| 570 |
+
{ name: "OnlyFans", type: "subscription", fee: "20%", color: "from-blue-500 to-cyan-500", legal: "Adult content permitido" },
|
| 571 |
+
{ name: "Patreon", type: "subscription", fee: "12%", color: "from-orange-500 to-red-500", legal: "Sin adult content real" },
|
| 572 |
+
{ name: "Fansly", type: "mixed", fee: "20%", color: "from-purple-500 to-pink-500", legal: "Adult content permitido" },
|
| 573 |
+
{ name: "Fanvue", type: "subscription", fee: "15%", color: "from-green-500 to-teal-500", legal: "Adult content permitido" },
|
| 574 |
+
{ name: "Ko-fi", type: "tips", fee: "0%", color: "from-sky-500 to-blue-500", legal: "Sin adult content" },
|
| 575 |
+
{ name: "Instagram", type: "free", fee: "0%", color: "from-pink-500 to-purple-500", legal: "Sin desnudez" },
|
| 576 |
+
].map((p) => (
|
| 577 |
+
<Card key={p.name} className="bg-slate-900/50 border-slate-800">
|
| 578 |
+
<CardContent className="p-4">
|
| 579 |
+
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${p.color} flex items-center justify-center mb-3`}>
|
| 580 |
+
<DollarSign className="h-5 w-5 text-white" />
|
| 581 |
+
</div>
|
| 582 |
+
<h3 className="font-semibold">{p.name}</h3>
|
| 583 |
+
<p className="text-xs text-slate-400 mt-1">Fee: {p.fee}</p>
|
| 584 |
+
<Badge variant="outline" className="mt-2 text-xs">{p.type}</Badge>
|
| 585 |
+
<p className="text-xs text-amber-400 mt-2">⚖️ {p.legal}</p>
|
| 586 |
+
<Button size="sm" className="w-full mt-3 bg-violet-600 hover:bg-violet-700">
|
| 587 |
+
<Plus className="h-3 w-3 mr-1" />Conectar
|
| 588 |
+
</Button>
|
| 589 |
+
</CardContent>
|
| 590 |
+
</Card>
|
| 591 |
+
))}
|
| 592 |
+
</div>
|
| 593 |
+
)}
|
| 594 |
+
|
| 595 |
+
{/* Posts */}
|
| 596 |
+
{activeTab === "posts" && (
|
| 597 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 598 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 599 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Calendar className="h-5 w-5 text-blue-400" />Crear Publicación</CardTitle></CardHeader>
|
| 600 |
+
<CardContent className="space-y-4">
|
| 601 |
+
<Input placeholder="Título" value={postTitle} onChange={(e) => setPostTitle(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 602 |
+
<Textarea placeholder="Caption..." value={postCaption} onChange={(e) => setPostCaption(e.target.value)} className="bg-slate-800 border-slate-700 min-h-20" />
|
| 603 |
+
<div className="grid grid-cols-2 gap-4">
|
| 604 |
+
<div>
|
| 605 |
+
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 606 |
+
<Select value={postType} onValueChange={setPostType}>
|
| 607 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 608 |
+
<SelectContent>
|
| 609 |
+
<SelectItem value="reel">Reel</SelectItem>
|
| 610 |
+
<SelectItem value="photo">Foto</SelectItem>
|
| 611 |
+
<SelectItem value="carousel">Carrusel</SelectItem>
|
| 612 |
+
<SelectItem value="story">Story</SelectItem>
|
| 613 |
+
</SelectContent>
|
| 614 |
+
</Select>
|
| 615 |
+
</div>
|
| 616 |
+
<div>
|
| 617 |
+
<Label className="text-xs text-slate-400">Programar</Label>
|
| 618 |
+
<Input type="datetime-local" value={scheduledTime} onChange={(e) => setScheduledTime(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 619 |
+
</div>
|
| 620 |
+
</div>
|
| 621 |
+
<Button onClick={handleCreatePost} className="w-full bg-blue-600 hover:bg-blue-700">
|
| 622 |
+
<Calendar className="h-4 w-4 mr-2" />{scheduledTime ? "Programar" : "Crear"}
|
| 623 |
+
</Button>
|
| 624 |
+
</CardContent>
|
| 625 |
+
</Card>
|
| 626 |
+
|
| 627 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 628 |
+
<CardHeader><CardTitle>Publicaciones</CardTitle></CardHeader>
|
| 629 |
+
<CardContent>
|
| 630 |
+
<ScrollArea className="h-64">
|
| 631 |
+
{posts.length === 0 ? (
|
| 632 |
+
<p className="text-slate-400 text-center py-8">No hay publicaciones</p>
|
| 633 |
+
) : (
|
| 634 |
+
<div className="space-y-2">
|
| 635 |
+
{posts.map((p) => (
|
| 636 |
+
<div key={p.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 637 |
+
<div>
|
| 638 |
+
<p className="font-medium text-sm">{p.title || "Sin título"}</p>
|
| 639 |
+
<p className="text-xs text-slate-400">{p.type} • {p.scheduledAt ? new Date(p.scheduledAt).toLocaleString() : "Borrador"}</p>
|
| 640 |
+
</div>
|
| 641 |
+
<Badge className={getStatusColor(p.status)}>{p.status}</Badge>
|
| 642 |
+
</div>
|
| 643 |
+
))}
|
| 644 |
+
</div>
|
| 645 |
+
)}
|
| 646 |
+
</ScrollArea>
|
| 647 |
+
</CardContent>
|
| 648 |
+
</Card>
|
| 649 |
+
</div>
|
| 650 |
+
)}
|
| 651 |
+
|
| 652 |
+
{/* Storytelling */}
|
| 653 |
+
{activeTab === "storytelling" && (
|
| 654 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 655 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 656 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Film className="h-5 w-5 text-purple-400" />Crear Historia</CardTitle></CardHeader>
|
| 657 |
+
<CardContent className="space-y-4">
|
| 658 |
+
<Textarea placeholder="Describe tu historia... Ej: Una historia de transformación fitness de 30 días..." value={storyPrompt} onChange={(e) => setStoryPrompt(e.target.value)} className="bg-slate-800 border-slate-700 min-h-24" />
|
| 659 |
+
<div className="grid grid-cols-2 gap-4">
|
| 660 |
+
<div>
|
| 661 |
+
<Label className="text-xs text-slate-400">Género</Label>
|
| 662 |
+
<Select value={storyGenre} onValueChange={setStoryGenre}>
|
| 663 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 664 |
+
<SelectContent>
|
| 665 |
+
<SelectItem value="lifestyle">Lifestyle</SelectItem>
|
| 666 |
+
<SelectItem value="fitness">Fitness</SelectItem>
|
| 667 |
+
<SelectItem value="romance">Romance</SelectItem>
|
| 668 |
+
<SelectItem value="drama">Drama</SelectItem>
|
| 669 |
+
<SelectItem value="comedy">Comedia</SelectItem>
|
| 670 |
+
</SelectContent>
|
| 671 |
+
</Select>
|
| 672 |
+
</div>
|
| 673 |
+
<div>
|
| 674 |
+
<Label className="text-xs text-slate-400">Episodios</Label>
|
| 675 |
+
<Input type="number" value={storyEpisodes} onChange={(e) => setStoryEpisodes(parseInt(e.target.value) || 7)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 676 |
+
</div>
|
| 677 |
+
</div>
|
| 678 |
+
<Button onClick={handleCreateStory} disabled={storyLoading} className="w-full bg-purple-600 hover:bg-purple-700">
|
| 679 |
+
{storyLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Sparkles className="h-4 w-4 mr-2" />}Generar Historia
|
| 680 |
+
</Button>
|
| 681 |
+
</CardContent>
|
| 682 |
+
</Card>
|
| 683 |
+
|
| 684 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 685 |
+
<CardHeader><CardTitle>Historias Creadas</CardTitle></CardHeader>
|
| 686 |
+
<CardContent>
|
| 687 |
+
<ScrollArea className="h-64">
|
| 688 |
+
{stories.length === 0 ? (
|
| 689 |
+
<p className="text-slate-400 text-center py-8">No hay historias</p>
|
| 690 |
+
) : (
|
| 691 |
+
<div className="space-y-2">
|
| 692 |
+
{stories.map((s) => (
|
| 693 |
+
<div key={s.id} className="p-3 rounded-lg bg-slate-800/50">
|
| 694 |
+
<div className="flex justify-between items-start">
|
| 695 |
+
<div>
|
| 696 |
+
<p className="font-medium">{s.title}</p>
|
| 697 |
+
<p className="text-xs text-slate-400">{s.totalEpisodes} episodios • {s.genre}</p>
|
| 698 |
+
</div>
|
| 699 |
+
<Badge className={getStatusColor(s.status)}>{s.status}</Badge>
|
| 700 |
+
</div>
|
| 701 |
+
</div>
|
| 702 |
+
))}
|
| 703 |
+
</div>
|
| 704 |
+
)}
|
| 705 |
+
</ScrollArea>
|
| 706 |
+
</CardContent>
|
| 707 |
+
</Card>
|
| 708 |
+
</div>
|
| 709 |
+
)}
|
| 710 |
+
|
| 711 |
+
{/* Automation */}
|
| 712 |
+
{activeTab === "automation" && (
|
| 713 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 714 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 715 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Zap className="h-5 w-5 text-amber-400" />Crear Automatización</CardTitle></CardHeader>
|
| 716 |
+
<CardContent className="space-y-4">
|
| 717 |
+
<Input placeholder="Nombre de la automatización" value={automationName} onChange={(e) => setAutomationName(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 718 |
+
<div>
|
| 719 |
+
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 720 |
+
<Select value={automationType} onValueChange={setAutomationType}>
|
| 721 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 722 |
+
<SelectContent>
|
| 723 |
+
<SelectItem value="content_generation">Generación de Contenido</SelectItem>
|
| 724 |
+
<SelectItem value="posting">Publicación Automática</SelectItem>
|
| 725 |
+
<SelectItem value="cross_posting">Cross-Posting</SelectItem>
|
| 726 |
+
<SelectItem value="trend_tracking">Seguimiento de Tendencias</SelectItem>
|
| 727 |
+
</SelectContent>
|
| 728 |
+
</Select>
|
| 729 |
+
</div>
|
| 730 |
+
<Button onClick={handleCreateAutomation} className="w-full bg-amber-600 hover:bg-amber-700">
|
| 731 |
+
<Zap className="h-4 w-4 mr-2" />Crear
|
| 732 |
+
</Button>
|
| 733 |
+
</CardContent>
|
| 734 |
+
</Card>
|
| 735 |
+
|
| 736 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 737 |
+
<CardHeader><CardTitle>Automatizaciones Activas</CardTitle></CardHeader>
|
| 738 |
+
<CardContent>
|
| 739 |
+
<ScrollArea className="h-64">
|
| 740 |
+
{automations.length === 0 ? (
|
| 741 |
+
<p className="text-slate-400 text-center py-8">No hay automatizaciones</p>
|
| 742 |
+
) : (
|
| 743 |
+
<div className="space-y-2">
|
| 744 |
+
{automations.map((a) => (
|
| 745 |
+
<div key={a.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 746 |
+
<div>
|
| 747 |
+
<p className="font-medium text-sm">{a.name}</p>
|
| 748 |
+
<p className="text-xs text-slate-400">{a.type} • {a.runCount} ejecuciones</p>
|
| 749 |
+
</div>
|
| 750 |
+
<Button size="sm" variant="ghost" onClick={() => handleToggleAutomation(a.id, a.isActive)}>
|
| 751 |
+
{a.isActive ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
| 752 |
+
</Button>
|
| 753 |
+
</div>
|
| 754 |
+
))}
|
| 755 |
+
</div>
|
| 756 |
+
)}
|
| 757 |
+
</ScrollArea>
|
| 758 |
+
</CardContent>
|
| 759 |
+
</Card>
|
| 760 |
+
</div>
|
| 761 |
+
)}
|
| 762 |
+
|
| 763 |
+
{/* Influencers IA */}
|
| 764 |
+
{activeTab === "influencers" && (
|
| 765 |
+
<div className="space-y-6">
|
| 766 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 767 |
+
<CardHeader>
|
| 768 |
+
<CardTitle className="flex items-center gap-2"><Users2 className="h-5 w-5 text-pink-400" />Análisis de Influencers IA Famosos</CardTitle>
|
| 769 |
+
<CardDescription>Estudia los patrones de influencers IA exitosos para aplicarlos a tu contenido</CardDescription>
|
| 770 |
+
</CardHeader>
|
| 771 |
+
<CardContent className="space-y-4">
|
| 772 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 773 |
+
<div>
|
| 774 |
+
<Label className="text-xs text-slate-400">Tu Nicho</Label>
|
| 775 |
+
<Input placeholder="lifestyle, fashion..." value={influencerNiche} onChange={(e) => setInfluencerNiche(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 776 |
+
</div>
|
| 777 |
+
<div>
|
| 778 |
+
<Label className="text-xs text-slate-400">Plataforma Objetivo</Label>
|
| 779 |
+
<Select value={influencerPlatform} onValueChange={setInfluencerPlatform}>
|
| 780 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue placeholder="Todas" /></SelectTrigger>
|
| 781 |
+
<SelectContent>
|
| 782 |
+
<SelectItem value="">Todas</SelectItem>
|
| 783 |
+
<SelectItem value="instagram">Instagram</SelectItem>
|
| 784 |
+
<SelectItem value="tiktok">TikTok</SelectItem>
|
| 785 |
+
<SelectItem value="youtube">YouTube</SelectItem>
|
| 786 |
+
</SelectContent>
|
| 787 |
+
</Select>
|
| 788 |
+
</div>
|
| 789 |
+
<div className="flex items-end">
|
| 790 |
+
<div className="flex items-center gap-2 pb-2">
|
| 791 |
+
<Switch checked={influencerWithPets} onCheckedChange={setInfluencerWithPets} />
|
| 792 |
+
<Label className="text-sm text-slate-400">Con mascotas</Label>
|
| 793 |
+
</div>
|
| 794 |
+
</div>
|
| 795 |
+
<div className="flex items-end">
|
| 796 |
+
<Button onClick={handleAnalyzeInfluencers} disabled={influencerLoading} className="w-full bg-pink-600 hover:bg-pink-700">
|
| 797 |
+
{influencerLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Lightbulb className="h-4 w-4 mr-2" />}Analizar
|
| 798 |
+
</Button>
|
| 799 |
+
</div>
|
| 800 |
+
</div>
|
| 801 |
+
</CardContent>
|
| 802 |
+
</Card>
|
| 803 |
+
|
| 804 |
+
{influencers.length > 0 && (
|
| 805 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 806 |
+
{influencers.map((inf, i) => (
|
| 807 |
+
<Card key={i} className="bg-slate-900/50 border-slate-800">
|
| 808 |
+
<CardContent className="p-4">
|
| 809 |
+
<div className="flex items-center gap-2 mb-2">
|
| 810 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-500 to-purple-500 flex items-center justify-center">
|
| 811 |
+
<Star className="h-4 w-4 text-white" />
|
| 812 |
+
</div>
|
| 813 |
+
<div>
|
| 814 |
+
<p className="font-medium text-sm">{inf.name}</p>
|
| 815 |
+
<p className="text-xs text-slate-400">{inf.handle}</p>
|
| 816 |
+
</div>
|
| 817 |
+
</div>
|
| 818 |
+
<div className="space-y-1 text-xs">
|
| 819 |
+
<p><span className="text-slate-400">Seguidores:</span> <span className="text-green-400">{(inf.followers / 1000000).toFixed(1)}M</span></p>
|
| 820 |
+
<p><span className="text-slate-400">Engagement:</span> <span className="text-blue-400">{inf.engagement}%</span></p>
|
| 821 |
+
<p><span className="text-slate-400">Nicho:</span> {inf.niche}</p>
|
| 822 |
+
{inf.petCompanion && <Badge className="bg-amber-500/20 text-amber-400 mt-1"><PawPrint className="h-3 w-3 mr-1" />{inf.petType}</Badge>}
|
| 823 |
+
</div>
|
| 824 |
+
<div className="mt-2 pt-2 border-t border-slate-700">
|
| 825 |
+
<p className="text-xs text-slate-400">Lecciones:</p>
|
| 826 |
+
<ul className="text-xs mt-1 space-y-1">
|
| 827 |
+
{inf.keyLessons.slice(0, 2).map((l, j) => (
|
| 828 |
+
<li key={j} className="text-slate-300">• {l}</li>
|
| 829 |
+
))}
|
| 830 |
+
</ul>
|
| 831 |
+
</div>
|
| 832 |
+
</CardContent>
|
| 833 |
+
</Card>
|
| 834 |
+
))}
|
| 835 |
+
</div>
|
| 836 |
+
)}
|
| 837 |
+
|
| 838 |
+
{characterConcept && (
|
| 839 |
+
<Card className="bg-slate-900/50 border-slate-800 border-violet-500/30">
|
| 840 |
+
<CardHeader>
|
| 841 |
+
<CardTitle className="flex items-center gap-2 text-violet-400"><Sparkles className="h-5 w-5" />Concepto de Personaje Sugerido</CardTitle>
|
| 842 |
+
</CardHeader>
|
| 843 |
+
<CardContent>
|
| 844 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 845 |
+
<div>
|
| 846 |
+
<h4 className="font-medium mb-2">Personaje</h4>
|
| 847 |
+
<div className="p-3 rounded-lg bg-slate-800/50 text-sm">
|
| 848 |
+
{characterConcept.character && (
|
| 849 |
+
<>
|
| 850 |
+
<p><strong>Nombre:</strong> {String(characterConcept.character.name || "")}</p>
|
| 851 |
+
<p className="mt-1"><strong>Personalidad:</strong> {String(characterConcept.character.personality || "")}</p>
|
| 852 |
+
<p className="mt-1"><strong>Historia:</strong> {String(characterConcept.character.backstory || "")}</p>
|
| 853 |
+
</>
|
| 854 |
+
)}
|
| 855 |
+
</div>
|
| 856 |
+
</div>
|
| 857 |
+
<div>
|
| 858 |
+
<h4 className="font-medium mb-2">Estilo Visual</h4>
|
| 859 |
+
<div className="p-3 rounded-lg bg-slate-800/50 text-sm">
|
| 860 |
+
{characterConcept.visualStyle && (
|
| 861 |
+
<>
|
| 862 |
+
<p><strong>Estética:</strong> {String(characterConcept.visualStyle.aesthetic || "")}</p>
|
| 863 |
+
<p className="mt-1"><strong>Colores:</strong> {Array.isArray(characterConcept.visualStyle.colors) ? characterConcept.visualStyle.colors.join(", ") : ""}</p>
|
| 864 |
+
</>
|
| 865 |
+
)}
|
| 866 |
+
</div>
|
| 867 |
+
{Array.isArray(characterConcept.hashtags) && characterConcept.hashtags.length > 0 && (
|
| 868 |
+
<div className="mt-3 flex flex-wrap gap-1">
|
| 869 |
+
{characterConcept.hashtags.slice(0, 10).map((tag: string, i: number) => (
|
| 870 |
+
<Badge key={i} variant="outline" className="text-xs">{tag}</Badge>
|
| 871 |
+
))}
|
| 872 |
+
</div>
|
| 873 |
+
)}
|
| 874 |
+
</div>
|
| 875 |
+
</div>
|
| 876 |
+
</CardContent>
|
| 877 |
+
</Card>
|
| 878 |
+
)}
|
| 879 |
+
</div>
|
| 880 |
+
)}
|
| 881 |
+
|
| 882 |
+
{/* Pets */}
|
| 883 |
+
{activeTab === "pets" && (
|
| 884 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 885 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 886 |
+
<CardHeader>
|
| 887 |
+
<CardTitle className="flex items-center gap-2"><PawPrint className="h-5 w-5 text-amber-400" />Crear Mascota</CardTitle>
|
| 888 |
+
<CardDescription>Las mascotas aumentan el engagement hasta un 35%</CardDescription>
|
| 889 |
+
</CardHeader>
|
| 890 |
+
<CardContent className="space-y-4">
|
| 891 |
+
<div className="grid grid-cols-2 gap-4">
|
| 892 |
+
<div>
|
| 893 |
+
<Label className="text-xs text-slate-400">Nombre</Label>
|
| 894 |
+
<Input placeholder="Max, Luna..." value={petName} onChange={(e) => setPetName(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 895 |
+
</div>
|
| 896 |
+
<div>
|
| 897 |
+
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 898 |
+
<Select value={petType} onValueChange={setPetType}>
|
| 899 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 900 |
+
<SelectContent>
|
| 901 |
+
{petTypes.map((pt) => (
|
| 902 |
+
<SelectItem key={pt.value} value={pt.value}>{pt.label}</SelectItem>
|
| 903 |
+
))}
|
| 904 |
+
</SelectContent>
|
| 905 |
+
</Select>
|
| 906 |
+
</div>
|
| 907 |
+
</div>
|
| 908 |
+
<div className="grid grid-cols-2 gap-4">
|
| 909 |
+
<div>
|
| 910 |
+
<Label className="text-xs text-slate-400">Raza (opcional)</Label>
|
| 911 |
+
<Input placeholder="Golden Retriever..." value={petBreed} onChange={(e) => setPetBreed(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 912 |
+
</div>
|
| 913 |
+
<div>
|
| 914 |
+
<Label className="text-xs text-slate-400">Personalidad</Label>
|
| 915 |
+
<Select value={petPersonality} onValueChange={setPetPersonality}>
|
| 916 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue placeholder="Seleccionar" /></SelectTrigger>
|
| 917 |
+
<SelectContent>
|
| 918 |
+
<SelectItem value="playful">Juguetón</SelectItem>
|
| 919 |
+
<SelectItem value="calm">Tranquilo</SelectItem>
|
| 920 |
+
<SelectItem value="energetic">Energético</SelectItem>
|
| 921 |
+
<SelectItem value="curious">Curioso</SelectItem>
|
| 922 |
+
<SelectItem value="lazy">Perezoso</SelectItem>
|
| 923 |
+
</SelectContent>
|
| 924 |
+
</Select>
|
| 925 |
+
</div>
|
| 926 |
+
</div>
|
| 927 |
+
<Button onClick={handleCreatePet} disabled={petLoading} className="w-full bg-amber-600 hover:bg-amber-700">
|
| 928 |
+
{petLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <PawPrint className="h-4 w-4 mr-2" />}Crear Mascota
|
| 929 |
+
</Button>
|
| 930 |
+
</CardContent>
|
| 931 |
+
</Card>
|
| 932 |
+
|
| 933 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 934 |
+
<CardHeader><CardTitle>Mis Mascotas</CardTitle></CardHeader>
|
| 935 |
+
<CardContent>
|
| 936 |
+
<ScrollArea className="h-64">
|
| 937 |
+
{pets.length === 0 ? (
|
| 938 |
+
<div className="text-center py-8 text-slate-400">
|
| 939 |
+
<PawPrint className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
| 940 |
+
<p>No tienes mascotas</p>
|
| 941 |
+
<p className="text-xs mt-1">Las mascotas aumentan el engagement</p>
|
| 942 |
+
</div>
|
| 943 |
+
) : (
|
| 944 |
+
<div className="space-y-2">
|
| 945 |
+
{pets.map((p) => (
|
| 946 |
+
<div key={p.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 947 |
+
<div className="flex items-center gap-3">
|
| 948 |
+
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center">
|
| 949 |
+
<PawPrint className="h-5 w-5 text-white" />
|
| 950 |
+
</div>
|
| 951 |
+
<div>
|
| 952 |
+
<p className="font-medium">{p.name}</p>
|
| 953 |
+
<p className="text-xs text-slate-400">{p.breed || p.type} • {p.personality || "Sin personalidad"}</p>
|
| 954 |
+
</div>
|
| 955 |
+
</div>
|
| 956 |
+
<Badge className="bg-green-500/20 text-green-400">+35%</Badge>
|
| 957 |
+
</div>
|
| 958 |
+
))}
|
| 959 |
+
</div>
|
| 960 |
+
)}
|
| 961 |
+
</ScrollArea>
|
| 962 |
+
</CardContent>
|
| 963 |
+
</Card>
|
| 964 |
+
</div>
|
| 965 |
+
)}
|
| 966 |
+
|
| 967 |
+
{/* Trends */}
|
| 968 |
+
{activeTab === "trends" && (
|
| 969 |
+
<div className="space-y-6">
|
| 970 |
+
<div className="flex items-center gap-4">
|
| 971 |
+
<div className="flex items-center gap-2">
|
| 972 |
+
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 973 |
+
<Label className="text-sm text-slate-400">Incluir tendencias de mascotas</Label>
|
| 974 |
+
</div>
|
| 975 |
+
<Button onClick={handleAnalyzeTrends} disabled={trendLoading} className="bg-violet-600 hover:bg-violet-700">
|
| 976 |
+
{trendLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}Analizar para Viralizar
|
| 977 |
+
</Button>
|
| 978 |
+
</div>
|
| 979 |
+
|
| 980 |
+
{/* Tendencias Actuales */}
|
| 981 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 982 |
+
{trends.slice(0, 8).map((t, i) => (
|
| 983 |
+
<Card key={i} className="bg-slate-900/50 border-slate-800">
|
| 984 |
+
<CardContent className="p-4">
|
| 985 |
+
<div className="flex items-center justify-between mb-2">
|
| 986 |
+
<Badge variant="outline" className="text-xs">{String(t.platform || "")}</Badge>
|
| 987 |
+
<Flame className={`h-4 w-4 ${Number(t.growth) > 30 ? "text-red-400" : "text-orange-400"}`} />
|
| 988 |
+
</div>
|
| 989 |
+
<p className="font-semibold">{String(t.name)}</p>
|
| 990 |
+
<p className="text-green-400 text-sm">+{Number(t.growth).toFixed(1)}%</p>
|
| 991 |
+
<p className="text-xs text-slate-400 mt-1">{String(t.category || "")}</p>
|
| 992 |
+
</CardContent>
|
| 993 |
+
</Card>
|
| 994 |
+
))}
|
| 995 |
+
</div>
|
| 996 |
+
|
| 997 |
+
{/* Estrategias Virales */}
|
| 998 |
+
{viralStrategies.length > 0 && (
|
| 999 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 1000 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Rocket className="h-5 w-5 text-violet-400" />Estrategias Virales</CardTitle></CardHeader>
|
| 1001 |
+
<CardContent>
|
| 1002 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 1003 |
+
{viralStrategies.slice(0, 4).map((s, i) => (
|
| 1004 |
+
<div key={i} className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 1005 |
+
<div className="flex items-center justify-between mb-2">
|
| 1006 |
+
<p className="font-medium text-sm">{String(s.name)}</p>
|
| 1007 |
+
<Badge className="bg-green-500/20 text-green-400 text-xs">{String(s.successRate)}%</Badge>
|
| 1008 |
+
</div>
|
| 1009 |
+
<p className="text-xs text-slate-400 line-clamp-2">{String(s.description)}</p>
|
| 1010 |
+
<div className="flex flex-wrap gap-1 mt-2">
|
| 1011 |
+
{Array.isArray(s.platforms) && s.platforms.slice(0, 3).map((p: string, j: number) => (
|
| 1012 |
+
<Badge key={j} variant="outline" className="text-xs">{p}</Badge>
|
| 1013 |
+
))}
|
| 1014 |
+
</div>
|
| 1015 |
+
</div>
|
| 1016 |
+
))}
|
| 1017 |
+
</div>
|
| 1018 |
+
</CardContent>
|
| 1019 |
+
</Card>
|
| 1020 |
+
)}
|
| 1021 |
+
|
| 1022 |
+
{/* Ideas de Contenido */}
|
| 1023 |
+
{contentIdeas.length > 0 && (
|
| 1024 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 1025 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Lightbulb className="h-5 w-5 text-amber-400" />Ideas de Contenido Viral</CardTitle></CardHeader>
|
| 1026 |
+
<CardContent>
|
| 1027 |
+
<div className="space-y-3">
|
| 1028 |
+
{contentIdeas.slice(0, 5).map((idea, i) => (
|
| 1029 |
+
<div key={i} className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 1030 |
+
<div className="flex items-center justify-between">
|
| 1031 |
+
<div>
|
| 1032 |
+
<p className="font-medium">{String(idea.title)}</p>
|
| 1033 |
+
<p className="text-xs text-slate-400 mt-1">{String(idea.description)}</p>
|
| 1034 |
+
{idea.hook && <p className="text-xs text-violet-400 mt-1">Hook: "{String(idea.hook)}"</p>}
|
| 1035 |
+
</div>
|
| 1036 |
+
<div className="text-right">
|
| 1037 |
+
<Badge className="bg-violet-500/20 text-violet-400">{String(idea.format)}</Badge>
|
| 1038 |
+
{idea.viralScore && <p className="text-xs text-green-400 mt-1">Score: {String(idea.viralScore)}</p>}
|
| 1039 |
+
</div>
|
| 1040 |
+
</div>
|
| 1041 |
+
</div>
|
| 1042 |
+
))}
|
| 1043 |
+
</div>
|
| 1044 |
+
</CardContent>
|
| 1045 |
+
</Card>
|
| 1046 |
+
)}
|
| 1047 |
+
|
| 1048 |
+
{/* Análisis de Tendencias */}
|
| 1049 |
+
{trendAnalysis && (
|
| 1050 |
+
<Card className="bg-slate-900/50 border-slate-800 border-violet-500/30">
|
| 1051 |
+
<CardHeader>
|
| 1052 |
+
<CardTitle className="flex items-center gap-2 text-violet-400"><Target className="h-5 w-5" />Plan para Viralizar</CardTitle>
|
| 1053 |
+
</CardHeader>
|
| 1054 |
+
<CardContent>
|
| 1055 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1056 |
+
<div>
|
| 1057 |
+
<h4 className="font-medium mb-2">Tendencias Emergentes</h4>
|
| 1058 |
+
<div className="space-y-2">
|
| 1059 |
+
{Array.isArray(trendAnalysis.emergingTrends) && trendAnalysis.emergingTrends.slice(0, 3).map((t: Record<string, unknown>, i: number) => (
|
| 1060 |
+
<div key={i} className="p-2 rounded bg-slate-800/50 text-sm">
|
| 1061 |
+
<p className="font-medium">{String(t.name)}</p>
|
| 1062 |
+
<p className="text-xs text-slate-400">Potencial: {String(t.potential)}</p>
|
| 1063 |
+
</div>
|
| 1064 |
+
))}
|
| 1065 |
+
</div>
|
| 1066 |
+
</div>
|
| 1067 |
+
<div>
|
| 1068 |
+
<h4 className="font-medium mb-2">Recomendaciones</h4>
|
| 1069 |
+
<div className="space-y-1">
|
| 1070 |
+
{Array.isArray(trendAnalysis.recommendations) && trendAnalysis.recommendations.slice(0, 4).map((r: string, i: number) => (
|
| 1071 |
+
<p key={i} className="text-sm text-slate-300">• {r}</p>
|
| 1072 |
+
))}
|
| 1073 |
+
</div>
|
| 1074 |
+
{trendAnalysis.predictedViralPotential && (
|
| 1075 |
+
<div className="mt-4 p-3 rounded-lg bg-violet-500/10 border border-violet-500/30">
|
| 1076 |
+
<p className="text-sm">Potencial Viral Estimado:</p>
|
| 1077 |
+
<p className="text-2xl font-bold text-violet-400">{String(trendAnalysis.predictedViralPotential)}%</p>
|
| 1078 |
+
</div>
|
| 1079 |
+
)}
|
| 1080 |
+
</div>
|
| 1081 |
+
</div>
|
| 1082 |
+
</CardContent>
|
| 1083 |
+
</Card>
|
| 1084 |
+
)}
|
| 1085 |
+
</div>
|
| 1086 |
+
)}
|
| 1087 |
+
|
| 1088 |
+
{/* Content */}
|
| 1089 |
+
{activeTab === "content" && (
|
| 1090 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 1091 |
+
<CardHeader><CardTitle>Contenido Generado</CardTitle></CardHeader>
|
| 1092 |
+
<CardContent>
|
| 1093 |
+
<ScrollArea className="h-96">
|
| 1094 |
+
{contents.length === 0 ? (
|
| 1095 |
+
<p className="text-slate-400 text-center py-8">No hay contenido generado</p>
|
| 1096 |
+
) : (
|
| 1097 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 1098 |
+
{contents.map((c) => (
|
| 1099 |
+
<div key={c.id} className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 1100 |
+
<div className="flex justify-between mb-2">
|
| 1101 |
+
<Badge className={getStatusColor(c.status)}>{c.status}</Badge>
|
| 1102 |
+
<Badge variant="outline">{c.type}</Badge>
|
| 1103 |
+
</div>
|
| 1104 |
+
<p className="font-medium line-clamp-2">{c.title}</p>
|
| 1105 |
+
<p className="text-xs text-slate-400 mt-1">{c.platform}</p>
|
| 1106 |
+
</div>
|
| 1107 |
+
))}
|
| 1108 |
+
</div>
|
| 1109 |
+
)}
|
| 1110 |
+
</ScrollArea>
|
| 1111 |
+
</CardContent>
|
| 1112 |
+
</Card>
|
| 1113 |
+
)}
|
| 1114 |
+
|
| 1115 |
+
{/* Videos */}
|
| 1116 |
+
{activeTab === "videos" && (
|
| 1117 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 1118 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Video className="h-5 w-5 text-blue-400" />Generar Videos</CardTitle></CardHeader>
|
| 1119 |
+
<CardContent className="space-y-4">
|
| 1120 |
+
<Textarea placeholder="Describe el video..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 1121 |
+
<Button className="w-full bg-blue-600 hover:bg-blue-700">
|
| 1122 |
+
<Video className="h-4 w-4 mr-2" />Generar Video
|
| 1123 |
+
</Button>
|
| 1124 |
+
</CardContent>
|
| 1125 |
+
</Card>
|
| 1126 |
+
)}
|
| 1127 |
+
</main>
|
| 1128 |
+
</div>
|
| 1129 |
+
</div>
|
| 1130 |
+
);
|
| 1131 |
+
}
|
src/components/ui/accordion.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
| 5 |
+
import { ChevronDownIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function Accordion({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
| 12 |
+
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AccordionItem({
|
| 16 |
+
className,
|
| 17 |
+
...props
|
| 18 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
| 19 |
+
return (
|
| 20 |
+
<AccordionPrimitive.Item
|
| 21 |
+
data-slot="accordion-item"
|
| 22 |
+
className={cn("border-b last:border-b-0", className)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function AccordionTrigger({
|
| 29 |
+
className,
|
| 30 |
+
children,
|
| 31 |
+
...props
|
| 32 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
| 33 |
+
return (
|
| 34 |
+
<AccordionPrimitive.Header className="flex">
|
| 35 |
+
<AccordionPrimitive.Trigger
|
| 36 |
+
data-slot="accordion-trigger"
|
| 37 |
+
className={cn(
|
| 38 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
| 39 |
+
className
|
| 40 |
+
)}
|
| 41 |
+
{...props}
|
| 42 |
+
>
|
| 43 |
+
{children}
|
| 44 |
+
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
| 45 |
+
</AccordionPrimitive.Trigger>
|
| 46 |
+
</AccordionPrimitive.Header>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AccordionContent({
|
| 51 |
+
className,
|
| 52 |
+
children,
|
| 53 |
+
...props
|
| 54 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
| 55 |
+
return (
|
| 56 |
+
<AccordionPrimitive.Content
|
| 57 |
+
data-slot="accordion-content"
|
| 58 |
+
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
| 59 |
+
{...props}
|
| 60 |
+
>
|
| 61 |
+
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
| 62 |
+
</AccordionPrimitive.Content>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
src/components/ui/alert-dialog.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
import { buttonVariants } from "@/components/ui/button"
|
| 8 |
+
|
| 9 |
+
function AlertDialog({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
| 12 |
+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AlertDialogTrigger({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
| 18 |
+
return (
|
| 19 |
+
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function AlertDialogPortal({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
| 26 |
+
return (
|
| 27 |
+
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function AlertDialogOverlay({
|
| 32 |
+
className,
|
| 33 |
+
...props
|
| 34 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
| 35 |
+
return (
|
| 36 |
+
<AlertDialogPrimitive.Overlay
|
| 37 |
+
data-slot="alert-dialog-overlay"
|
| 38 |
+
className={cn(
|
| 39 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
| 40 |
+
className
|
| 41 |
+
)}
|
| 42 |
+
{...props}
|
| 43 |
+
/>
|
| 44 |
+
)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function AlertDialogContent({
|
| 48 |
+
className,
|
| 49 |
+
...props
|
| 50 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
| 51 |
+
return (
|
| 52 |
+
<AlertDialogPortal>
|
| 53 |
+
<AlertDialogOverlay />
|
| 54 |
+
<AlertDialogPrimitive.Content
|
| 55 |
+
data-slot="alert-dialog-content"
|
| 56 |
+
className={cn(
|
| 57 |
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
| 58 |
+
className
|
| 59 |
+
)}
|
| 60 |
+
{...props}
|
| 61 |
+
/>
|
| 62 |
+
</AlertDialogPortal>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function AlertDialogHeader({
|
| 67 |
+
className,
|
| 68 |
+
...props
|
| 69 |
+
}: React.ComponentProps<"div">) {
|
| 70 |
+
return (
|
| 71 |
+
<div
|
| 72 |
+
data-slot="alert-dialog-header"
|
| 73 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
| 74 |
+
{...props}
|
| 75 |
+
/>
|
| 76 |
+
)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function AlertDialogFooter({
|
| 80 |
+
className,
|
| 81 |
+
...props
|
| 82 |
+
}: React.ComponentProps<"div">) {
|
| 83 |
+
return (
|
| 84 |
+
<div
|
| 85 |
+
data-slot="alert-dialog-footer"
|
| 86 |
+
className={cn(
|
| 87 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 88 |
+
className
|
| 89 |
+
)}
|
| 90 |
+
{...props}
|
| 91 |
+
/>
|
| 92 |
+
)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function AlertDialogTitle({
|
| 96 |
+
className,
|
| 97 |
+
...props
|
| 98 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
| 99 |
+
return (
|
| 100 |
+
<AlertDialogPrimitive.Title
|
| 101 |
+
data-slot="alert-dialog-title"
|
| 102 |
+
className={cn("text-lg font-semibold", className)}
|
| 103 |
+
{...props}
|
| 104 |
+
/>
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function AlertDialogDescription({
|
| 109 |
+
className,
|
| 110 |
+
...props
|
| 111 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
| 112 |
+
return (
|
| 113 |
+
<AlertDialogPrimitive.Description
|
| 114 |
+
data-slot="alert-dialog-description"
|
| 115 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 116 |
+
{...props}
|
| 117 |
+
/>
|
| 118 |
+
)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function AlertDialogAction({
|
| 122 |
+
className,
|
| 123 |
+
...props
|
| 124 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
| 125 |
+
return (
|
| 126 |
+
<AlertDialogPrimitive.Action
|
| 127 |
+
className={cn(buttonVariants(), className)}
|
| 128 |
+
{...props}
|
| 129 |
+
/>
|
| 130 |
+
)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function AlertDialogCancel({
|
| 134 |
+
className,
|
| 135 |
+
...props
|
| 136 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
| 137 |
+
return (
|
| 138 |
+
<AlertDialogPrimitive.Cancel
|
| 139 |
+
className={cn(buttonVariants({ variant: "outline" }), className)}
|
| 140 |
+
{...props}
|
| 141 |
+
/>
|
| 142 |
+
)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
export {
|
| 146 |
+
AlertDialog,
|
| 147 |
+
AlertDialogPortal,
|
| 148 |
+
AlertDialogOverlay,
|
| 149 |
+
AlertDialogTrigger,
|
| 150 |
+
AlertDialogContent,
|
| 151 |
+
AlertDialogHeader,
|
| 152 |
+
AlertDialogFooter,
|
| 153 |
+
AlertDialogTitle,
|
| 154 |
+
AlertDialogDescription,
|
| 155 |
+
AlertDialogAction,
|
| 156 |
+
AlertDialogCancel,
|
| 157 |
+
}
|
src/components/ui/alert.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
const alertVariants = cva(
|
| 7 |
+
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-card text-card-foreground",
|
| 12 |
+
destructive:
|
| 13 |
+
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
| 14 |
+
},
|
| 15 |
+
},
|
| 16 |
+
defaultVariants: {
|
| 17 |
+
variant: "default",
|
| 18 |
+
},
|
| 19 |
+
}
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
function Alert({
|
| 23 |
+
className,
|
| 24 |
+
variant,
|
| 25 |
+
...props
|
| 26 |
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
data-slot="alert"
|
| 30 |
+
role="alert"
|
| 31 |
+
className={cn(alertVariants({ variant }), className)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 38 |
+
return (
|
| 39 |
+
<div
|
| 40 |
+
data-slot="alert-title"
|
| 41 |
+
className={cn(
|
| 42 |
+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
/>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AlertDescription({
|
| 51 |
+
className,
|
| 52 |
+
...props
|
| 53 |
+
}: React.ComponentProps<"div">) {
|
| 54 |
+
return (
|
| 55 |
+
<div
|
| 56 |
+
data-slot="alert-description"
|
| 57 |
+
className={cn(
|
| 58 |
+
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
| 59 |
+
className
|
| 60 |
+
)}
|
| 61 |
+
{...props}
|
| 62 |
+
/>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Alert, AlertTitle, AlertDescription }
|
src/components/ui/aspect-ratio.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
| 4 |
+
|
| 5 |
+
function AspectRatio({
|
| 6 |
+
...props
|
| 7 |
+
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
| 8 |
+
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export { AspectRatio }
|
src/components/ui/avatar.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Avatar({
|
| 9 |
+
className,
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
| 12 |
+
return (
|
| 13 |
+
<AvatarPrimitive.Root
|
| 14 |
+
data-slot="avatar"
|
| 15 |
+
className={cn(
|
| 16 |
+
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
| 17 |
+
className
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function AvatarImage({
|
| 25 |
+
className,
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
| 28 |
+
return (
|
| 29 |
+
<AvatarPrimitive.Image
|
| 30 |
+
data-slot="avatar-image"
|
| 31 |
+
className={cn("aspect-square size-full", className)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function AvatarFallback({
|
| 38 |
+
className,
|
| 39 |
+
...props
|
| 40 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
| 41 |
+
return (
|
| 42 |
+
<AvatarPrimitive.Fallback
|
| 43 |
+
data-slot="avatar-fallback"
|
| 44 |
+
className={cn(
|
| 45 |
+
"bg-muted flex size-full items-center justify-center rounded-full",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
/>
|
| 50 |
+
)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export { Avatar, AvatarImage, AvatarFallback }
|
src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const badgeVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default:
|
| 13 |
+
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
| 14 |
+
secondary:
|
| 15 |
+
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
| 16 |
+
destructive:
|
| 17 |
+
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 18 |
+
outline:
|
| 19 |
+
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
defaultVariants: {
|
| 23 |
+
variant: "default",
|
| 24 |
+
},
|
| 25 |
+
}
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
function Badge({
|
| 29 |
+
className,
|
| 30 |
+
variant,
|
| 31 |
+
asChild = false,
|
| 32 |
+
...props
|
| 33 |
+
}: React.ComponentProps<"span"> &
|
| 34 |
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
| 35 |
+
const Comp = asChild ? Slot : "span"
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<Comp
|
| 39 |
+
data-slot="badge"
|
| 40 |
+
className={cn(badgeVariants({ variant }), className)}
|
| 41 |
+
{...props}
|
| 42 |
+
/>
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export { Badge, badgeVariants }
|
src/components/ui/breadcrumb.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
| 8 |
+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
| 12 |
+
return (
|
| 13 |
+
<ol
|
| 14 |
+
data-slot="breadcrumb-list"
|
| 15 |
+
className={cn(
|
| 16 |
+
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
| 17 |
+
className
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
| 25 |
+
return (
|
| 26 |
+
<li
|
| 27 |
+
data-slot="breadcrumb-item"
|
| 28 |
+
className={cn("inline-flex items-center gap-1.5", className)}
|
| 29 |
+
{...props}
|
| 30 |
+
/>
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function BreadcrumbLink({
|
| 35 |
+
asChild,
|
| 36 |
+
className,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<"a"> & {
|
| 39 |
+
asChild?: boolean
|
| 40 |
+
}) {
|
| 41 |
+
const Comp = asChild ? Slot : "a"
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<Comp
|
| 45 |
+
data-slot="breadcrumb-link"
|
| 46 |
+
className={cn("hover:text-foreground transition-colors", className)}
|
| 47 |
+
{...props}
|
| 48 |
+
/>
|
| 49 |
+
)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
| 53 |
+
return (
|
| 54 |
+
<span
|
| 55 |
+
data-slot="breadcrumb-page"
|
| 56 |
+
role="link"
|
| 57 |
+
aria-disabled="true"
|
| 58 |
+
aria-current="page"
|
| 59 |
+
className={cn("text-foreground font-normal", className)}
|
| 60 |
+
{...props}
|
| 61 |
+
/>
|
| 62 |
+
)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function BreadcrumbSeparator({
|
| 66 |
+
children,
|
| 67 |
+
className,
|
| 68 |
+
...props
|
| 69 |
+
}: React.ComponentProps<"li">) {
|
| 70 |
+
return (
|
| 71 |
+
<li
|
| 72 |
+
data-slot="breadcrumb-separator"
|
| 73 |
+
role="presentation"
|
| 74 |
+
aria-hidden="true"
|
| 75 |
+
className={cn("[&>svg]:size-3.5", className)}
|
| 76 |
+
{...props}
|
| 77 |
+
>
|
| 78 |
+
{children ?? <ChevronRight />}
|
| 79 |
+
</li>
|
| 80 |
+
)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function BreadcrumbEllipsis({
|
| 84 |
+
className,
|
| 85 |
+
...props
|
| 86 |
+
}: React.ComponentProps<"span">) {
|
| 87 |
+
return (
|
| 88 |
+
<span
|
| 89 |
+
data-slot="breadcrumb-ellipsis"
|
| 90 |
+
role="presentation"
|
| 91 |
+
aria-hidden="true"
|
| 92 |
+
className={cn("flex size-9 items-center justify-center", className)}
|
| 93 |
+
{...props}
|
| 94 |
+
>
|
| 95 |
+
<MoreHorizontal className="size-4" />
|
| 96 |
+
<span className="sr-only">More</span>
|
| 97 |
+
</span>
|
| 98 |
+
)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export {
|
| 102 |
+
Breadcrumb,
|
| 103 |
+
BreadcrumbList,
|
| 104 |
+
BreadcrumbItem,
|
| 105 |
+
BreadcrumbLink,
|
| 106 |
+
BreadcrumbPage,
|
| 107 |
+
BreadcrumbSeparator,
|
| 108 |
+
BreadcrumbEllipsis,
|
| 109 |
+
}
|
src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default:
|
| 13 |
+
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
| 14 |
+
destructive:
|
| 15 |
+
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 16 |
+
outline:
|
| 17 |
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
| 18 |
+
secondary:
|
| 19 |
+
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
| 20 |
+
ghost:
|
| 21 |
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
| 22 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 23 |
+
},
|
| 24 |
+
size: {
|
| 25 |
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
| 26 |
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
| 27 |
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
| 28 |
+
icon: "size-9",
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
defaultVariants: {
|
| 32 |
+
variant: "default",
|
| 33 |
+
size: "default",
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
function Button({
|
| 39 |
+
className,
|
| 40 |
+
variant,
|
| 41 |
+
size,
|
| 42 |
+
asChild = false,
|
| 43 |
+
...props
|
| 44 |
+
}: React.ComponentProps<"button"> &
|
| 45 |
+
VariantProps<typeof buttonVariants> & {
|
| 46 |
+
asChild?: boolean
|
| 47 |
+
}) {
|
| 48 |
+
const Comp = asChild ? Slot : "button"
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<Comp
|
| 52 |
+
data-slot="button"
|
| 53 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 54 |
+
{...props}
|
| 55 |
+
/>
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export { Button, buttonVariants }
|
src/components/ui/calendar.tsx
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import {
|
| 5 |
+
ChevronDownIcon,
|
| 6 |
+
ChevronLeftIcon,
|
| 7 |
+
ChevronRightIcon,
|
| 8 |
+
} from "lucide-react"
|
| 9 |
+
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
| 10 |
+
|
| 11 |
+
import { cn } from "@/lib/utils"
|
| 12 |
+
import { Button, buttonVariants } from "@/components/ui/button"
|
| 13 |
+
|
| 14 |
+
function Calendar({
|
| 15 |
+
className,
|
| 16 |
+
classNames,
|
| 17 |
+
showOutsideDays = true,
|
| 18 |
+
captionLayout = "label",
|
| 19 |
+
buttonVariant = "ghost",
|
| 20 |
+
formatters,
|
| 21 |
+
components,
|
| 22 |
+
...props
|
| 23 |
+
}: React.ComponentProps<typeof DayPicker> & {
|
| 24 |
+
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
| 25 |
+
}) {
|
| 26 |
+
const defaultClassNames = getDefaultClassNames()
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<DayPicker
|
| 30 |
+
showOutsideDays={showOutsideDays}
|
| 31 |
+
className={cn(
|
| 32 |
+
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
| 33 |
+
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
| 34 |
+
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
| 35 |
+
className
|
| 36 |
+
)}
|
| 37 |
+
captionLayout={captionLayout}
|
| 38 |
+
formatters={{
|
| 39 |
+
formatMonthDropdown: (date) =>
|
| 40 |
+
date.toLocaleString("default", { month: "short" }),
|
| 41 |
+
...formatters,
|
| 42 |
+
}}
|
| 43 |
+
classNames={{
|
| 44 |
+
root: cn("w-fit", defaultClassNames.root),
|
| 45 |
+
months: cn(
|
| 46 |
+
"flex gap-4 flex-col md:flex-row relative",
|
| 47 |
+
defaultClassNames.months
|
| 48 |
+
),
|
| 49 |
+
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
| 50 |
+
nav: cn(
|
| 51 |
+
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
| 52 |
+
defaultClassNames.nav
|
| 53 |
+
),
|
| 54 |
+
button_previous: cn(
|
| 55 |
+
buttonVariants({ variant: buttonVariant }),
|
| 56 |
+
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
| 57 |
+
defaultClassNames.button_previous
|
| 58 |
+
),
|
| 59 |
+
button_next: cn(
|
| 60 |
+
buttonVariants({ variant: buttonVariant }),
|
| 61 |
+
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
| 62 |
+
defaultClassNames.button_next
|
| 63 |
+
),
|
| 64 |
+
month_caption: cn(
|
| 65 |
+
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
| 66 |
+
defaultClassNames.month_caption
|
| 67 |
+
),
|
| 68 |
+
dropdowns: cn(
|
| 69 |
+
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
| 70 |
+
defaultClassNames.dropdowns
|
| 71 |
+
),
|
| 72 |
+
dropdown_root: cn(
|
| 73 |
+
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
| 74 |
+
defaultClassNames.dropdown_root
|
| 75 |
+
),
|
| 76 |
+
dropdown: cn(
|
| 77 |
+
"absolute bg-popover inset-0 opacity-0",
|
| 78 |
+
defaultClassNames.dropdown
|
| 79 |
+
),
|
| 80 |
+
caption_label: cn(
|
| 81 |
+
"select-none font-medium",
|
| 82 |
+
captionLayout === "label"
|
| 83 |
+
? "text-sm"
|
| 84 |
+
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
| 85 |
+
defaultClassNames.caption_label
|
| 86 |
+
),
|
| 87 |
+
table: "w-full border-collapse",
|
| 88 |
+
weekdays: cn("flex", defaultClassNames.weekdays),
|
| 89 |
+
weekday: cn(
|
| 90 |
+
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
| 91 |
+
defaultClassNames.weekday
|
| 92 |
+
),
|
| 93 |
+
week: cn("flex w-full mt-2", defaultClassNames.week),
|
| 94 |
+
week_number_header: cn(
|
| 95 |
+
"select-none w-(--cell-size)",
|
| 96 |
+
defaultClassNames.week_number_header
|
| 97 |
+
),
|
| 98 |
+
week_number: cn(
|
| 99 |
+
"text-[0.8rem] select-none text-muted-foreground",
|
| 100 |
+
defaultClassNames.week_number
|
| 101 |
+
),
|
| 102 |
+
day: cn(
|
| 103 |
+
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
| 104 |
+
defaultClassNames.day
|
| 105 |
+
),
|
| 106 |
+
range_start: cn(
|
| 107 |
+
"rounded-l-md bg-accent",
|
| 108 |
+
defaultClassNames.range_start
|
| 109 |
+
),
|
| 110 |
+
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
| 111 |
+
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
| 112 |
+
today: cn(
|
| 113 |
+
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
| 114 |
+
defaultClassNames.today
|
| 115 |
+
),
|
| 116 |
+
outside: cn(
|
| 117 |
+
"text-muted-foreground aria-selected:text-muted-foreground",
|
| 118 |
+
defaultClassNames.outside
|
| 119 |
+
),
|
| 120 |
+
disabled: cn(
|
| 121 |
+
"text-muted-foreground opacity-50",
|
| 122 |
+
defaultClassNames.disabled
|
| 123 |
+
),
|
| 124 |
+
hidden: cn("invisible", defaultClassNames.hidden),
|
| 125 |
+
...classNames,
|
| 126 |
+
}}
|
| 127 |
+
components={{
|
| 128 |
+
Root: ({ className, rootRef, ...props }) => {
|
| 129 |
+
return (
|
| 130 |
+
<div
|
| 131 |
+
data-slot="calendar"
|
| 132 |
+
ref={rootRef}
|
| 133 |
+
className={cn(className)}
|
| 134 |
+
{...props}
|
| 135 |
+
/>
|
| 136 |
+
)
|
| 137 |
+
},
|
| 138 |
+
Chevron: ({ className, orientation, ...props }) => {
|
| 139 |
+
if (orientation === "left") {
|
| 140 |
+
return (
|
| 141 |
+
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
| 142 |
+
)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (orientation === "right") {
|
| 146 |
+
return (
|
| 147 |
+
<ChevronRightIcon
|
| 148 |
+
className={cn("size-4", className)}
|
| 149 |
+
{...props}
|
| 150 |
+
/>
|
| 151 |
+
)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return (
|
| 155 |
+
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
| 156 |
+
)
|
| 157 |
+
},
|
| 158 |
+
DayButton: CalendarDayButton,
|
| 159 |
+
WeekNumber: ({ children, ...props }) => {
|
| 160 |
+
return (
|
| 161 |
+
<td {...props}>
|
| 162 |
+
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
| 163 |
+
{children}
|
| 164 |
+
</div>
|
| 165 |
+
</td>
|
| 166 |
+
)
|
| 167 |
+
},
|
| 168 |
+
...components,
|
| 169 |
+
}}
|
| 170 |
+
{...props}
|
| 171 |
+
/>
|
| 172 |
+
)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function CalendarDayButton({
|
| 176 |
+
className,
|
| 177 |
+
day,
|
| 178 |
+
modifiers,
|
| 179 |
+
...props
|
| 180 |
+
}: React.ComponentProps<typeof DayButton>) {
|
| 181 |
+
const defaultClassNames = getDefaultClassNames()
|
| 182 |
+
|
| 183 |
+
const ref = React.useRef<HTMLButtonElement>(null)
|
| 184 |
+
React.useEffect(() => {
|
| 185 |
+
if (modifiers.focused) ref.current?.focus()
|
| 186 |
+
}, [modifiers.focused])
|
| 187 |
+
|
| 188 |
+
return (
|
| 189 |
+
<Button
|
| 190 |
+
ref={ref}
|
| 191 |
+
variant="ghost"
|
| 192 |
+
size="icon"
|
| 193 |
+
data-day={day.date.toLocaleDateString()}
|
| 194 |
+
data-selected-single={
|
| 195 |
+
modifiers.selected &&
|
| 196 |
+
!modifiers.range_start &&
|
| 197 |
+
!modifiers.range_end &&
|
| 198 |
+
!modifiers.range_middle
|
| 199 |
+
}
|
| 200 |
+
data-range-start={modifiers.range_start}
|
| 201 |
+
data-range-end={modifiers.range_end}
|
| 202 |
+
data-range-middle={modifiers.range_middle}
|
| 203 |
+
className={cn(
|
| 204 |
+
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
| 205 |
+
defaultClassNames.day,
|
| 206 |
+
className
|
| 207 |
+
)}
|
| 208 |
+
{...props}
|
| 209 |
+
/>
|
| 210 |
+
)
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
export { Calendar, CalendarDayButton }
|
src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
| 6 |
+
return (
|
| 7 |
+
<div
|
| 8 |
+
data-slot="card"
|
| 9 |
+
className={cn(
|
| 10 |
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 19 |
+
return (
|
| 20 |
+
<div
|
| 21 |
+
data-slot="card-header"
|
| 22 |
+
className={cn(
|
| 23 |
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
| 24 |
+
className
|
| 25 |
+
)}
|
| 26 |
+
{...props}
|
| 27 |
+
/>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 32 |
+
return (
|
| 33 |
+
<div
|
| 34 |
+
data-slot="card-title"
|
| 35 |
+
className={cn("leading-none font-semibold", className)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
| 42 |
+
return (
|
| 43 |
+
<div
|
| 44 |
+
data-slot="card-description"
|
| 45 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 46 |
+
{...props}
|
| 47 |
+
/>
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
| 52 |
+
return (
|
| 53 |
+
<div
|
| 54 |
+
data-slot="card-action"
|
| 55 |
+
className={cn(
|
| 56 |
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
| 57 |
+
className
|
| 58 |
+
)}
|
| 59 |
+
{...props}
|
| 60 |
+
/>
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 65 |
+
return (
|
| 66 |
+
<div
|
| 67 |
+
data-slot="card-content"
|
| 68 |
+
className={cn("px-6", className)}
|
| 69 |
+
{...props}
|
| 70 |
+
/>
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 75 |
+
return (
|
| 76 |
+
<div
|
| 77 |
+
data-slot="card-footer"
|
| 78 |
+
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
| 79 |
+
{...props}
|
| 80 |
+
/>
|
| 81 |
+
)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export {
|
| 85 |
+
Card,
|
| 86 |
+
CardHeader,
|
| 87 |
+
CardFooter,
|
| 88 |
+
CardTitle,
|
| 89 |
+
CardAction,
|
| 90 |
+
CardDescription,
|
| 91 |
+
CardContent,
|
| 92 |
+
}
|
src/components/ui/carousel.tsx
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import useEmblaCarousel, {
|
| 5 |
+
type UseEmblaCarouselType,
|
| 6 |
+
} from "embla-carousel-react"
|
| 7 |
+
import { ArrowLeft, ArrowRight } from "lucide-react"
|
| 8 |
+
|
| 9 |
+
import { cn } from "@/lib/utils"
|
| 10 |
+
import { Button } from "@/components/ui/button"
|
| 11 |
+
|
| 12 |
+
type CarouselApi = UseEmblaCarouselType[1]
|
| 13 |
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
| 14 |
+
type CarouselOptions = UseCarouselParameters[0]
|
| 15 |
+
type CarouselPlugin = UseCarouselParameters[1]
|
| 16 |
+
|
| 17 |
+
type CarouselProps = {
|
| 18 |
+
opts?: CarouselOptions
|
| 19 |
+
plugins?: CarouselPlugin
|
| 20 |
+
orientation?: "horizontal" | "vertical"
|
| 21 |
+
setApi?: (api: CarouselApi) => void
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
type CarouselContextProps = {
|
| 25 |
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
| 26 |
+
api: ReturnType<typeof useEmblaCarousel>[1]
|
| 27 |
+
scrollPrev: () => void
|
| 28 |
+
scrollNext: () => void
|
| 29 |
+
canScrollPrev: boolean
|
| 30 |
+
canScrollNext: boolean
|
| 31 |
+
} & CarouselProps
|
| 32 |
+
|
| 33 |
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
| 34 |
+
|
| 35 |
+
function useCarousel() {
|
| 36 |
+
const context = React.useContext(CarouselContext)
|
| 37 |
+
|
| 38 |
+
if (!context) {
|
| 39 |
+
throw new Error("useCarousel must be used within a <Carousel />")
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return context
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function Carousel({
|
| 46 |
+
orientation = "horizontal",
|
| 47 |
+
opts,
|
| 48 |
+
setApi,
|
| 49 |
+
plugins,
|
| 50 |
+
className,
|
| 51 |
+
children,
|
| 52 |
+
...props
|
| 53 |
+
}: React.ComponentProps<"div"> & CarouselProps) {
|
| 54 |
+
const [carouselRef, api] = useEmblaCarousel(
|
| 55 |
+
{
|
| 56 |
+
...opts,
|
| 57 |
+
axis: orientation === "horizontal" ? "x" : "y",
|
| 58 |
+
},
|
| 59 |
+
plugins
|
| 60 |
+
)
|
| 61 |
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
| 62 |
+
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
| 63 |
+
|
| 64 |
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
| 65 |
+
if (!api) return
|
| 66 |
+
setCanScrollPrev(api.canScrollPrev())
|
| 67 |
+
setCanScrollNext(api.canScrollNext())
|
| 68 |
+
}, [])
|
| 69 |
+
|
| 70 |
+
const scrollPrev = React.useCallback(() => {
|
| 71 |
+
api?.scrollPrev()
|
| 72 |
+
}, [api])
|
| 73 |
+
|
| 74 |
+
const scrollNext = React.useCallback(() => {
|
| 75 |
+
api?.scrollNext()
|
| 76 |
+
}, [api])
|
| 77 |
+
|
| 78 |
+
const handleKeyDown = React.useCallback(
|
| 79 |
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
| 80 |
+
if (event.key === "ArrowLeft") {
|
| 81 |
+
event.preventDefault()
|
| 82 |
+
scrollPrev()
|
| 83 |
+
} else if (event.key === "ArrowRight") {
|
| 84 |
+
event.preventDefault()
|
| 85 |
+
scrollNext()
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
[scrollPrev, scrollNext]
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
React.useEffect(() => {
|
| 92 |
+
if (!api || !setApi) return
|
| 93 |
+
setApi(api)
|
| 94 |
+
}, [api, setApi])
|
| 95 |
+
|
| 96 |
+
React.useEffect(() => {
|
| 97 |
+
if (!api) return
|
| 98 |
+
onSelect(api)
|
| 99 |
+
api.on("reInit", onSelect)
|
| 100 |
+
api.on("select", onSelect)
|
| 101 |
+
|
| 102 |
+
return () => {
|
| 103 |
+
api?.off("select", onSelect)
|
| 104 |
+
}
|
| 105 |
+
}, [api, onSelect])
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<CarouselContext.Provider
|
| 109 |
+
value={{
|
| 110 |
+
carouselRef,
|
| 111 |
+
api: api,
|
| 112 |
+
opts,
|
| 113 |
+
orientation:
|
| 114 |
+
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
| 115 |
+
scrollPrev,
|
| 116 |
+
scrollNext,
|
| 117 |
+
canScrollPrev,
|
| 118 |
+
canScrollNext,
|
| 119 |
+
}}
|
| 120 |
+
>
|
| 121 |
+
<div
|
| 122 |
+
onKeyDownCapture={handleKeyDown}
|
| 123 |
+
className={cn("relative", className)}
|
| 124 |
+
role="region"
|
| 125 |
+
aria-roledescription="carousel"
|
| 126 |
+
data-slot="carousel"
|
| 127 |
+
{...props}
|
| 128 |
+
>
|
| 129 |
+
{children}
|
| 130 |
+
</div>
|
| 131 |
+
</CarouselContext.Provider>
|
| 132 |
+
)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 136 |
+
const { carouselRef, orientation } = useCarousel()
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div
|
| 140 |
+
ref={carouselRef}
|
| 141 |
+
className="overflow-hidden"
|
| 142 |
+
data-slot="carousel-content"
|
| 143 |
+
>
|
| 144 |
+
<div
|
| 145 |
+
className={cn(
|
| 146 |
+
"flex",
|
| 147 |
+
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
| 148 |
+
className
|
| 149 |
+
)}
|
| 150 |
+
{...props}
|
| 151 |
+
/>
|
| 152 |
+
</div>
|
| 153 |
+
)
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
| 157 |
+
const { orientation } = useCarousel()
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<div
|
| 161 |
+
role="group"
|
| 162 |
+
aria-roledescription="slide"
|
| 163 |
+
data-slot="carousel-item"
|
| 164 |
+
className={cn(
|
| 165 |
+
"min-w-0 shrink-0 grow-0 basis-full",
|
| 166 |
+
orientation === "horizontal" ? "pl-4" : "pt-4",
|
| 167 |
+
className
|
| 168 |
+
)}
|
| 169 |
+
{...props}
|
| 170 |
+
/>
|
| 171 |
+
)
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function CarouselPrevious({
|
| 175 |
+
className,
|
| 176 |
+
variant = "outline",
|
| 177 |
+
size = "icon",
|
| 178 |
+
...props
|
| 179 |
+
}: React.ComponentProps<typeof Button>) {
|
| 180 |
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
| 181 |
+
|
| 182 |
+
return (
|
| 183 |
+
<Button
|
| 184 |
+
data-slot="carousel-previous"
|
| 185 |
+
variant={variant}
|
| 186 |
+
size={size}
|
| 187 |
+
className={cn(
|
| 188 |
+
"absolute size-8 rounded-full",
|
| 189 |
+
orientation === "horizontal"
|
| 190 |
+
? "top-1/2 -left-12 -translate-y-1/2"
|
| 191 |
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 192 |
+
className
|
| 193 |
+
)}
|
| 194 |
+
disabled={!canScrollPrev}
|
| 195 |
+
onClick={scrollPrev}
|
| 196 |
+
{...props}
|
| 197 |
+
>
|
| 198 |
+
<ArrowLeft />
|
| 199 |
+
<span className="sr-only">Previous slide</span>
|
| 200 |
+
</Button>
|
| 201 |
+
)
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function CarouselNext({
|
| 205 |
+
className,
|
| 206 |
+
variant = "outline",
|
| 207 |
+
size = "icon",
|
| 208 |
+
...props
|
| 209 |
+
}: React.ComponentProps<typeof Button>) {
|
| 210 |
+
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
| 211 |
+
|
| 212 |
+
return (
|
| 213 |
+
<Button
|
| 214 |
+
data-slot="carousel-next"
|
| 215 |
+
variant={variant}
|
| 216 |
+
size={size}
|
| 217 |
+
className={cn(
|
| 218 |
+
"absolute size-8 rounded-full",
|
| 219 |
+
orientation === "horizontal"
|
| 220 |
+
? "top-1/2 -right-12 -translate-y-1/2"
|
| 221 |
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 222 |
+
className
|
| 223 |
+
)}
|
| 224 |
+
disabled={!canScrollNext}
|
| 225 |
+
onClick={scrollNext}
|
| 226 |
+
{...props}
|
| 227 |
+
>
|
| 228 |
+
<ArrowRight />
|
| 229 |
+
<span className="sr-only">Next slide</span>
|
| 230 |
+
</Button>
|
| 231 |
+
)
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
export {
|
| 235 |
+
type CarouselApi,
|
| 236 |
+
Carousel,
|
| 237 |
+
CarouselContent,
|
| 238 |
+
CarouselItem,
|
| 239 |
+
CarouselPrevious,
|
| 240 |
+
CarouselNext,
|
| 241 |
+
}
|
src/components/ui/chart.tsx
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as RechartsPrimitive from "recharts"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
// Format: { THEME_NAME: CSS_SELECTOR }
|
| 9 |
+
const THEMES = { light: "", dark: ".dark" } as const
|
| 10 |
+
|
| 11 |
+
export type ChartConfig = {
|
| 12 |
+
[k in string]: {
|
| 13 |
+
label?: React.ReactNode
|
| 14 |
+
icon?: React.ComponentType
|
| 15 |
+
} & (
|
| 16 |
+
| { color?: string; theme?: never }
|
| 17 |
+
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
type ChartContextProps = {
|
| 22 |
+
config: ChartConfig
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
| 26 |
+
|
| 27 |
+
function useChart() {
|
| 28 |
+
const context = React.useContext(ChartContext)
|
| 29 |
+
|
| 30 |
+
if (!context) {
|
| 31 |
+
throw new Error("useChart must be used within a <ChartContainer />")
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return context
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function ChartContainer({
|
| 38 |
+
id,
|
| 39 |
+
className,
|
| 40 |
+
children,
|
| 41 |
+
config,
|
| 42 |
+
...props
|
| 43 |
+
}: React.ComponentProps<"div"> & {
|
| 44 |
+
config: ChartConfig
|
| 45 |
+
children: React.ComponentProps<
|
| 46 |
+
typeof RechartsPrimitive.ResponsiveContainer
|
| 47 |
+
>["children"]
|
| 48 |
+
}) {
|
| 49 |
+
const uniqueId = React.useId()
|
| 50 |
+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<ChartContext.Provider value={{ config }}>
|
| 54 |
+
<div
|
| 55 |
+
data-slot="chart"
|
| 56 |
+
data-chart={chartId}
|
| 57 |
+
className={cn(
|
| 58 |
+
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
| 59 |
+
className
|
| 60 |
+
)}
|
| 61 |
+
{...props}
|
| 62 |
+
>
|
| 63 |
+
<ChartStyle id={chartId} config={config} />
|
| 64 |
+
<RechartsPrimitive.ResponsiveContainer>
|
| 65 |
+
{children}
|
| 66 |
+
</RechartsPrimitive.ResponsiveContainer>
|
| 67 |
+
</div>
|
| 68 |
+
</ChartContext.Provider>
|
| 69 |
+
)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
| 73 |
+
const colorConfig = Object.entries(config).filter(
|
| 74 |
+
([, config]) => config.theme || config.color
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if (!colorConfig.length) {
|
| 78 |
+
return null
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<style
|
| 83 |
+
dangerouslySetInnerHTML={{
|
| 84 |
+
__html: Object.entries(THEMES)
|
| 85 |
+
.map(
|
| 86 |
+
([theme, prefix]) => `
|
| 87 |
+
${prefix} [data-chart=${id}] {
|
| 88 |
+
${colorConfig
|
| 89 |
+
.map(([key, itemConfig]) => {
|
| 90 |
+
const color =
|
| 91 |
+
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
| 92 |
+
itemConfig.color
|
| 93 |
+
return color ? ` --color-${key}: ${color};` : null
|
| 94 |
+
})
|
| 95 |
+
.join("\n")}
|
| 96 |
+
}
|
| 97 |
+
`
|
| 98 |
+
)
|
| 99 |
+
.join("\n"),
|
| 100 |
+
}}
|
| 101 |
+
/>
|
| 102 |
+
)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const ChartTooltip = RechartsPrimitive.Tooltip
|
| 106 |
+
|
| 107 |
+
function ChartTooltipContent({
|
| 108 |
+
active,
|
| 109 |
+
payload,
|
| 110 |
+
className,
|
| 111 |
+
indicator = "dot",
|
| 112 |
+
hideLabel = false,
|
| 113 |
+
hideIndicator = false,
|
| 114 |
+
label,
|
| 115 |
+
labelFormatter,
|
| 116 |
+
labelClassName,
|
| 117 |
+
formatter,
|
| 118 |
+
color,
|
| 119 |
+
nameKey,
|
| 120 |
+
labelKey,
|
| 121 |
+
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
| 122 |
+
React.ComponentProps<"div"> & {
|
| 123 |
+
hideLabel?: boolean
|
| 124 |
+
hideIndicator?: boolean
|
| 125 |
+
indicator?: "line" | "dot" | "dashed"
|
| 126 |
+
nameKey?: string
|
| 127 |
+
labelKey?: string
|
| 128 |
+
}) {
|
| 129 |
+
const { config } = useChart()
|
| 130 |
+
|
| 131 |
+
const tooltipLabel = React.useMemo(() => {
|
| 132 |
+
if (hideLabel || !payload?.length) {
|
| 133 |
+
return null
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const [item] = payload
|
| 137 |
+
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
| 138 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
| 139 |
+
const value =
|
| 140 |
+
!labelKey && typeof label === "string"
|
| 141 |
+
? config[label as keyof typeof config]?.label || label
|
| 142 |
+
: itemConfig?.label
|
| 143 |
+
|
| 144 |
+
if (labelFormatter) {
|
| 145 |
+
return (
|
| 146 |
+
<div className={cn("font-medium", labelClassName)}>
|
| 147 |
+
{labelFormatter(value, payload)}
|
| 148 |
+
</div>
|
| 149 |
+
)
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if (!value) {
|
| 153 |
+
return null
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
| 157 |
+
}, [
|
| 158 |
+
label,
|
| 159 |
+
labelFormatter,
|
| 160 |
+
payload,
|
| 161 |
+
hideLabel,
|
| 162 |
+
labelClassName,
|
| 163 |
+
config,
|
| 164 |
+
labelKey,
|
| 165 |
+
])
|
| 166 |
+
|
| 167 |
+
if (!active || !payload?.length) {
|
| 168 |
+
return null
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const nestLabel = payload.length === 1 && indicator !== "dot"
|
| 172 |
+
|
| 173 |
+
return (
|
| 174 |
+
<div
|
| 175 |
+
className={cn(
|
| 176 |
+
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
| 177 |
+
className
|
| 178 |
+
)}
|
| 179 |
+
>
|
| 180 |
+
{!nestLabel ? tooltipLabel : null}
|
| 181 |
+
<div className="grid gap-1.5">
|
| 182 |
+
{payload.map((item, index) => {
|
| 183 |
+
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
| 184 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
| 185 |
+
const indicatorColor = color || item.payload.fill || item.color
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<div
|
| 189 |
+
key={item.dataKey}
|
| 190 |
+
className={cn(
|
| 191 |
+
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
| 192 |
+
indicator === "dot" && "items-center"
|
| 193 |
+
)}
|
| 194 |
+
>
|
| 195 |
+
{formatter && item?.value !== undefined && item.name ? (
|
| 196 |
+
formatter(item.value, item.name, item, index, item.payload)
|
| 197 |
+
) : (
|
| 198 |
+
<>
|
| 199 |
+
{itemConfig?.icon ? (
|
| 200 |
+
<itemConfig.icon />
|
| 201 |
+
) : (
|
| 202 |
+
!hideIndicator && (
|
| 203 |
+
<div
|
| 204 |
+
className={cn(
|
| 205 |
+
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
| 206 |
+
{
|
| 207 |
+
"h-2.5 w-2.5": indicator === "dot",
|
| 208 |
+
"w-1": indicator === "line",
|
| 209 |
+
"w-0 border-[1.5px] border-dashed bg-transparent":
|
| 210 |
+
indicator === "dashed",
|
| 211 |
+
"my-0.5": nestLabel && indicator === "dashed",
|
| 212 |
+
}
|
| 213 |
+
)}
|
| 214 |
+
style={
|
| 215 |
+
{
|
| 216 |
+
"--color-bg": indicatorColor,
|
| 217 |
+
"--color-border": indicatorColor,
|
| 218 |
+
} as React.CSSProperties
|
| 219 |
+
}
|
| 220 |
+
/>
|
| 221 |
+
)
|
| 222 |
+
)}
|
| 223 |
+
<div
|
| 224 |
+
className={cn(
|
| 225 |
+
"flex flex-1 justify-between leading-none",
|
| 226 |
+
nestLabel ? "items-end" : "items-center"
|
| 227 |
+
)}
|
| 228 |
+
>
|
| 229 |
+
<div className="grid gap-1.5">
|
| 230 |
+
{nestLabel ? tooltipLabel : null}
|
| 231 |
+
<span className="text-muted-foreground">
|
| 232 |
+
{itemConfig?.label || item.name}
|
| 233 |
+
</span>
|
| 234 |
+
</div>
|
| 235 |
+
{item.value && (
|
| 236 |
+
<span className="text-foreground font-mono font-medium tabular-nums">
|
| 237 |
+
{item.value.toLocaleString()}
|
| 238 |
+
</span>
|
| 239 |
+
)}
|
| 240 |
+
</div>
|
| 241 |
+
</>
|
| 242 |
+
)}
|
| 243 |
+
</div>
|
| 244 |
+
)
|
| 245 |
+
})}
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
)
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const ChartLegend = RechartsPrimitive.Legend
|
| 252 |
+
|
| 253 |
+
function ChartLegendContent({
|
| 254 |
+
className,
|
| 255 |
+
hideIcon = false,
|
| 256 |
+
payload,
|
| 257 |
+
verticalAlign = "bottom",
|
| 258 |
+
nameKey,
|
| 259 |
+
}: React.ComponentProps<"div"> &
|
| 260 |
+
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
| 261 |
+
hideIcon?: boolean
|
| 262 |
+
nameKey?: string
|
| 263 |
+
}) {
|
| 264 |
+
const { config } = useChart()
|
| 265 |
+
|
| 266 |
+
if (!payload?.length) {
|
| 267 |
+
return null
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
return (
|
| 271 |
+
<div
|
| 272 |
+
className={cn(
|
| 273 |
+
"flex items-center justify-center gap-4",
|
| 274 |
+
verticalAlign === "top" ? "pb-3" : "pt-3",
|
| 275 |
+
className
|
| 276 |
+
)}
|
| 277 |
+
>
|
| 278 |
+
{payload.map((item) => {
|
| 279 |
+
const key = `${nameKey || item.dataKey || "value"}`
|
| 280 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
| 281 |
+
|
| 282 |
+
return (
|
| 283 |
+
<div
|
| 284 |
+
key={item.value}
|
| 285 |
+
className={cn(
|
| 286 |
+
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
| 287 |
+
)}
|
| 288 |
+
>
|
| 289 |
+
{itemConfig?.icon && !hideIcon ? (
|
| 290 |
+
<itemConfig.icon />
|
| 291 |
+
) : (
|
| 292 |
+
<div
|
| 293 |
+
className="h-2 w-2 shrink-0 rounded-[2px]"
|
| 294 |
+
style={{
|
| 295 |
+
backgroundColor: item.color,
|
| 296 |
+
}}
|
| 297 |
+
/>
|
| 298 |
+
)}
|
| 299 |
+
{itemConfig?.label}
|
| 300 |
+
</div>
|
| 301 |
+
)
|
| 302 |
+
})}
|
| 303 |
+
</div>
|
| 304 |
+
)
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Helper to extract item config from a payload.
|
| 308 |
+
function getPayloadConfigFromPayload(
|
| 309 |
+
config: ChartConfig,
|
| 310 |
+
payload: unknown,
|
| 311 |
+
key: string
|
| 312 |
+
) {
|
| 313 |
+
if (typeof payload !== "object" || payload === null) {
|
| 314 |
+
return undefined
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
const payloadPayload =
|
| 318 |
+
"payload" in payload &&
|
| 319 |
+
typeof payload.payload === "object" &&
|
| 320 |
+
payload.payload !== null
|
| 321 |
+
? payload.payload
|
| 322 |
+
: undefined
|
| 323 |
+
|
| 324 |
+
let configLabelKey: string = key
|
| 325 |
+
|
| 326 |
+
if (
|
| 327 |
+
key in payload &&
|
| 328 |
+
typeof payload[key as keyof typeof payload] === "string"
|
| 329 |
+
) {
|
| 330 |
+
configLabelKey = payload[key as keyof typeof payload] as string
|
| 331 |
+
} else if (
|
| 332 |
+
payloadPayload &&
|
| 333 |
+
key in payloadPayload &&
|
| 334 |
+
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
| 335 |
+
) {
|
| 336 |
+
configLabelKey = payloadPayload[
|
| 337 |
+
key as keyof typeof payloadPayload
|
| 338 |
+
] as string
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
return configLabelKey in config
|
| 342 |
+
? config[configLabelKey]
|
| 343 |
+
: config[key as keyof typeof config]
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
export {
|
| 347 |
+
ChartContainer,
|
| 348 |
+
ChartTooltip,
|
| 349 |
+
ChartTooltipContent,
|
| 350 |
+
ChartLegend,
|
| 351 |
+
ChartLegendContent,
|
| 352 |
+
ChartStyle,
|
| 353 |
+
}
|
src/components/ui/checkbox.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
| 5 |
+
import { CheckIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function Checkbox({
|
| 10 |
+
className,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<CheckboxPrimitive.Root
|
| 15 |
+
data-slot="checkbox"
|
| 16 |
+
className={cn(
|
| 17 |
+
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
| 18 |
+
className
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
>
|
| 22 |
+
<CheckboxPrimitive.Indicator
|
| 23 |
+
data-slot="checkbox-indicator"
|
| 24 |
+
className="flex items-center justify-center text-current transition-none"
|
| 25 |
+
>
|
| 26 |
+
<CheckIcon className="size-3.5" />
|
| 27 |
+
</CheckboxPrimitive.Indicator>
|
| 28 |
+
</CheckboxPrimitive.Root>
|
| 29 |
+
)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export { Checkbox }
|
src/components/ui/collapsible.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
| 4 |
+
|
| 5 |
+
function Collapsible({
|
| 6 |
+
...props
|
| 7 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
| 8 |
+
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function CollapsibleTrigger({
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
| 14 |
+
return (
|
| 15 |
+
<CollapsiblePrimitive.CollapsibleTrigger
|
| 16 |
+
data-slot="collapsible-trigger"
|
| 17 |
+
{...props}
|
| 18 |
+
/>
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function CollapsibleContent({
|
| 23 |
+
...props
|
| 24 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
| 25 |
+
return (
|
| 26 |
+
<CollapsiblePrimitive.CollapsibleContent
|
| 27 |
+
data-slot="collapsible-content"
|
| 28 |
+
{...props}
|
| 29 |
+
/>
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
src/components/ui/command.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Command as CommandPrimitive } from "cmdk"
|
| 5 |
+
import { SearchIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
import {
|
| 9 |
+
Dialog,
|
| 10 |
+
DialogContent,
|
| 11 |
+
DialogDescription,
|
| 12 |
+
DialogHeader,
|
| 13 |
+
DialogTitle,
|
| 14 |
+
} from "@/components/ui/dialog"
|
| 15 |
+
|
| 16 |
+
function Command({
|
| 17 |
+
className,
|
| 18 |
+
...props
|
| 19 |
+
}: React.ComponentProps<typeof CommandPrimitive>) {
|
| 20 |
+
return (
|
| 21 |
+
<CommandPrimitive
|
| 22 |
+
data-slot="command"
|
| 23 |
+
className={cn(
|
| 24 |
+
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
| 25 |
+
className
|
| 26 |
+
)}
|
| 27 |
+
{...props}
|
| 28 |
+
/>
|
| 29 |
+
)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function CommandDialog({
|
| 33 |
+
title = "Command Palette",
|
| 34 |
+
description = "Search for a command to run...",
|
| 35 |
+
children,
|
| 36 |
+
className,
|
| 37 |
+
showCloseButton = true,
|
| 38 |
+
...props
|
| 39 |
+
}: React.ComponentProps<typeof Dialog> & {
|
| 40 |
+
title?: string
|
| 41 |
+
description?: string
|
| 42 |
+
className?: string
|
| 43 |
+
showCloseButton?: boolean
|
| 44 |
+
}) {
|
| 45 |
+
return (
|
| 46 |
+
<Dialog {...props}>
|
| 47 |
+
<DialogHeader className="sr-only">
|
| 48 |
+
<DialogTitle>{title}</DialogTitle>
|
| 49 |
+
<DialogDescription>{description}</DialogDescription>
|
| 50 |
+
</DialogHeader>
|
| 51 |
+
<DialogContent
|
| 52 |
+
className={cn("overflow-hidden p-0", className)}
|
| 53 |
+
showCloseButton={showCloseButton}
|
| 54 |
+
>
|
| 55 |
+
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
| 56 |
+
{children}
|
| 57 |
+
</Command>
|
| 58 |
+
</DialogContent>
|
| 59 |
+
</Dialog>
|
| 60 |
+
)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function CommandInput({
|
| 64 |
+
className,
|
| 65 |
+
...props
|
| 66 |
+
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
| 67 |
+
return (
|
| 68 |
+
<div
|
| 69 |
+
data-slot="command-input-wrapper"
|
| 70 |
+
className="flex h-9 items-center gap-2 border-b px-3"
|
| 71 |
+
>
|
| 72 |
+
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
| 73 |
+
<CommandPrimitive.Input
|
| 74 |
+
data-slot="command-input"
|
| 75 |
+
className={cn(
|
| 76 |
+
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
| 77 |
+
className
|
| 78 |
+
)}
|
| 79 |
+
{...props}
|
| 80 |
+
/>
|
| 81 |
+
</div>
|
| 82 |
+
)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function CommandList({
|
| 86 |
+
className,
|
| 87 |
+
...props
|
| 88 |
+
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
| 89 |
+
return (
|
| 90 |
+
<CommandPrimitive.List
|
| 91 |
+
data-slot="command-list"
|
| 92 |
+
className={cn(
|
| 93 |
+
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
| 94 |
+
className
|
| 95 |
+
)}
|
| 96 |
+
{...props}
|
| 97 |
+
/>
|
| 98 |
+
)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function CommandEmpty({
|
| 102 |
+
...props
|
| 103 |
+
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
| 104 |
+
return (
|
| 105 |
+
<CommandPrimitive.Empty
|
| 106 |
+
data-slot="command-empty"
|
| 107 |
+
className="py-6 text-center text-sm"
|
| 108 |
+
{...props}
|
| 109 |
+
/>
|
| 110 |
+
)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function CommandGroup({
|
| 114 |
+
className,
|
| 115 |
+
...props
|
| 116 |
+
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
| 117 |
+
return (
|
| 118 |
+
<CommandPrimitive.Group
|
| 119 |
+
data-slot="command-group"
|
| 120 |
+
className={cn(
|
| 121 |
+
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
| 122 |
+
className
|
| 123 |
+
)}
|
| 124 |
+
{...props}
|
| 125 |
+
/>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function CommandSeparator({
|
| 130 |
+
className,
|
| 131 |
+
...props
|
| 132 |
+
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
| 133 |
+
return (
|
| 134 |
+
<CommandPrimitive.Separator
|
| 135 |
+
data-slot="command-separator"
|
| 136 |
+
className={cn("bg-border -mx-1 h-px", className)}
|
| 137 |
+
{...props}
|
| 138 |
+
/>
|
| 139 |
+
)
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function CommandItem({
|
| 143 |
+
className,
|
| 144 |
+
...props
|
| 145 |
+
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
| 146 |
+
return (
|
| 147 |
+
<CommandPrimitive.Item
|
| 148 |
+
data-slot="command-item"
|
| 149 |
+
className={cn(
|
| 150 |
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 151 |
+
className
|
| 152 |
+
)}
|
| 153 |
+
{...props}
|
| 154 |
+
/>
|
| 155 |
+
)
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function CommandShortcut({
|
| 159 |
+
className,
|
| 160 |
+
...props
|
| 161 |
+
}: React.ComponentProps<"span">) {
|
| 162 |
+
return (
|
| 163 |
+
<span
|
| 164 |
+
data-slot="command-shortcut"
|
| 165 |
+
className={cn(
|
| 166 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
| 167 |
+
className
|
| 168 |
+
)}
|
| 169 |
+
{...props}
|
| 170 |
+
/>
|
| 171 |
+
)
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export {
|
| 175 |
+
Command,
|
| 176 |
+
CommandDialog,
|
| 177 |
+
CommandInput,
|
| 178 |
+
CommandList,
|
| 179 |
+
CommandEmpty,
|
| 180 |
+
CommandGroup,
|
| 181 |
+
CommandItem,
|
| 182 |
+
CommandShortcut,
|
| 183 |
+
CommandSeparator,
|
| 184 |
+
}
|