├── .gitignore
├── .idx
└── dev.nix
├── .modified
├── .vscode
├── extensions.json
└── settings.json
├── CODE_OF_CONDUCT.md
├── CODE_OF_CONDUCT_ES.md
├── CONTRIBUTING.md
├── CONTRIBUTING_ES.md
├── GUIA_USUARIO.md
├── README.es.md
├── README.md
├── SECURITY.md
├── SECURITY_ES.md
├── USER_GUIDE.md
├── components.json
├── ctf_writeup_builder-main.code-workspace
├── docs
├── blueprint.md
├── logo.png
└── screenshots
│ ├── ai-editor.png
│ ├── export.png
│ └── main-view.png
├── favicon.ico.png
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── src
├── ai
│ ├── dev.ts
│ ├── flows
│ │ ├── pdf-processor-flow.ts
│ │ └── section-suggester-flow.ts
│ └── genkit.ts
├── app
│ ├── [locale]
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── api
│ │ └── ai-generate
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components
│ ├── AboutModal.tsx
│ ├── ActiveSectionEditor.tsx
│ ├── ApiKeyConfigModal.tsx
│ ├── AppLayout.tsx
│ ├── GeneralInfoPanel.tsx
│ ├── HelpModal.tsx
│ ├── ImageUploader.tsx
│ ├── MarkdownEditor.tsx
│ ├── PdfExportModal.tsx
│ ├── SectionItemCard.tsx
│ ├── SectionsManager.tsx
│ ├── TagInput.tsx
│ ├── WriteUpPreview.tsx
│ ├── WysiwygEditor.tsx
│ └── ui
│ │ ├── accordion.tsx
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── avatar.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── card.tsx
│ │ ├── chart.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── menubar.tsx
│ │ ├── popover.tsx
│ │ ├── progress.tsx
│ │ ├── radio-group.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── sidebar.tsx
│ │ ├── skeleton.tsx
│ │ ├── slider.tsx
│ │ ├── switch.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ └── tooltip.tsx
├── contexts
│ └── WriteUpContext.tsx
├── hooks
│ ├── use-mobile.tsx
│ ├── use-toast.ts
│ └── useWriteUp.ts
├── lib
│ ├── constants.ts
│ ├── types.ts
│ └── utils.ts
├── locales
│ ├── client.ts
│ ├── en
│ │ └── index.ts
│ ├── es
│ │ └── index.ts
│ ├── i18n.ts
│ └── server.ts
├── middleware.ts
└── utils
│ └── pdfExtractorEnhanced.ts
├── tailwind.config.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # vercel
34 | .vercel
35 |
36 | # typescript
37 | *.tsbuildinfo
38 | next-env.d.ts
39 |
40 | .genkit/*
41 | .env*
42 |
43 | # firebase
44 | firebase-debug.log
45 | firestore-debug.log
--------------------------------------------------------------------------------
/.idx/dev.nix:
--------------------------------------------------------------------------------
1 | # To learn more about how to use Nix to configure your environment
2 | # see: https://firebase.google.com/docs/studio/customize-workspace
3 | {pkgs}: {
4 | # Which nixpkgs channel to use.
5 | channel = "stable-24.11"; # or "unstable"
6 | # Use https://search.nixos.org/packages to find packages
7 | packages = [
8 | pkgs.nodejs_20
9 | pkgs.zulu
10 | ];
11 | # Sets environment variables in the workspace
12 | env = {};
13 | # This adds a file watcher to startup the firebase emulators. The emulators will only start if
14 | # a firebase.json file is written into the user's directory
15 | services.firebase.emulators = {
16 | detect = true;
17 | projectId = "demo-app";
18 | services = ["auth" "firestore"];
19 | };
20 | idx = {
21 | # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
22 | extensions = [
23 | # "vscodevim.vim"
24 | ];
25 | workspace = {
26 | onCreate = {
27 | default.openFiles = [
28 | "src/app/page.tsx"
29 | ];
30 | };
31 | };
32 | # Enable previews and customize configuration
33 | previews = {
34 | enable = true;
35 | previews = {
36 | web = {
37 | command = ["npm" "run" "dev" "--" "--port" "$PORT" "--hostname" "0.0.0.0"];
38 | manager = "web";
39 | };
40 | };
41 | };
42 | };
43 | }
44 |
--------------------------------------------------------------------------------
/.modified:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilanami/ctf_writeup_builder/09d0939aed968f56dcd6fd568b16d7560ebd947a/.modified
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "github.copilot"
4 | ]
5 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "IDX.aI.enableInlineCompletion": true,
3 | "IDX.aI.enableCodebaseIndexing": true
4 | }
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | ## Our Pledge
4 | We as members, contributors, and maintainers pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
5 |
6 | ## Our Standards
7 | - Be respectful and considerate.
8 | - No harassment, bullying, or discrimination.
9 | - Use inclusive and welcoming language.
10 | - Respect differing viewpoints and experiences.
11 | - Accept constructive criticism gracefully.
12 | - Focus on what is best for the community.
13 |
14 | ## Reporting Issues
15 | If you experience or witness unacceptable behavior, please report it to: **writeup_builder@proton.me**
16 |
17 | ## Enforcement
18 | - Project maintainers are responsible for clarifying standards and enforcing this code of conduct.
19 | - Maintainers have the right to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct.
20 |
21 | ## Attribution
22 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/).
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT_ES.md:
--------------------------------------------------------------------------------
1 | # Código de Conducta
2 |
3 | ## Nuestro Compromiso
4 | Como miembros, contribuyentes y mantenedores, nos comprometemos a que la participación en este proyecto y comunidad sea una experiencia libre de acoso para todos, sin importar edad, tamaño corporal, discapacidad visible o invisible, etnia, características sexuales, identidad y expresión de género, nivel de experiencia, educación, estatus socioeconómico, nacionalidad, apariencia personal, raza, religión o identidad y orientación sexual.
5 |
6 | ## Nuestros Estándares
7 | - Sé respetuoso y considerado.
8 | - No se tolera acoso, bullying ni discriminación.
9 | - Usa un lenguaje inclusivo y acogedor.
10 | - Respeta puntos de vista y experiencias diferentes.
11 | - Acepta críticas constructivas con humildad.
12 | - Enfócate en lo mejor para la comunidad.
13 |
14 | ## Reporte de Incidentes
15 | Si experimentas o presencias un comportamiento inaceptable, repórtalo a: **writeup_builder@proton.me**
16 |
17 | ## Aplicación
18 | - Los mantenedores del proyecto son responsables de aclarar y hacer cumplir este código de conducta.
19 | - Los mantenedores pueden eliminar, editar o rechazar comentarios, commits, código, ediciones de wiki, issues y otras contribuciones que no se alineen con este código.
20 |
21 | ## Atribución
22 | Este Código de Conducta está adaptado del [Contributor Covenant](https://www.contributor-covenant.org/).
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guide
2 |
3 | Thank you for your interest in contributing to CTF Write-up Builder!
4 |
5 | ## How to Contribute
6 | - Fork the repository and create your branch from `master`.
7 | - Follow the code style and conventions used in the project.
8 | - Write clear, descriptive commit messages.
9 | - Test your changes locally before submitting a pull request.
10 | - Ensure your code does not introduce security vulnerabilities.
11 |
12 | ## Security Best Practices
13 | - Sanitize all user input and output. Use [DOMPurify](https://github.com/cure53/DOMPurify) for any HTML rendering.
14 | - Never commit secrets, API keys, or credentials to the repository.
15 | - Validate and restrict file uploads and imports.
16 | - Follow the principle of least privilege in all code and configuration.
17 |
18 | ## Reporting Security Issues
19 | If you find a security issue, please report it privately to **writeup_builder@proton.me**. Do not open a public issue.
20 |
21 | ## Responsible Disclosure
22 | - Give us reasonable time to address the issue before public disclosure.
23 | - Provide clear steps to reproduce the vulnerability.
24 |
25 | ## Community Standards
26 | - Follow our [Code of Conduct](./CODE_OF_CONDUCT.md).
27 | - Be respectful and collaborative.
28 |
29 | We appreciate your help in making CTF Write-up Builder better and more secure!
--------------------------------------------------------------------------------
/CONTRIBUTING_ES.md:
--------------------------------------------------------------------------------
1 | # Guía de Contribución
2 |
3 | ¡Gracias por tu interés en contribuir a CTF Write-up Builder!
4 |
5 | ## ¿Cómo Contribuir?
6 | - Haz un fork del repositorio y crea tu rama a partir de `master`.
7 | - Sigue el estilo y convenciones de código del proyecto.
8 | - Escribe mensajes de commit claros y descriptivos.
9 | - Prueba tus cambios localmente antes de enviar un pull request.
10 | - Asegúrate de que tu código no introduzca vulnerabilidades de seguridad.
11 |
12 | ## Buenas Prácticas de Seguridad
13 | - Sanitiza todo input y output de usuario. Usa [DOMPurify](https://github.com/cure53/DOMPurify) para cualquier renderizado HTML.
14 | - Nunca subas secretos, API keys o credenciales al repositorio.
15 | - Valida y restringe los archivos subidos e importados.
16 | - Aplica el principio de mínimo privilegio en todo el código y configuración.
17 |
18 | ## Reporte de Problemas de Seguridad
19 | Si encuentras un problema de seguridad, repórtalo de forma privada a **writeup_builder@proton.me**. No abras un issue público.
20 |
21 | ## Divulgación Responsable
22 | - Da un tiempo razonable para que el equipo resuelva el problema antes de divulgarlo públicamente.
23 | - Proporciona pasos claros para reproducir la vulnerabilidad.
24 |
25 | ## Estándares de Comunidad
26 | - Sigue nuestro [Código de Conducta](./CODE_OF_CONDUCT_ES.md).
27 | - Sé respetuoso y colaborativo.
28 |
29 | ¡Agradecemos tu ayuda para mejorar y asegurar CTF Write-up Builder!
--------------------------------------------------------------------------------
/GUIA_USUARIO.md:
--------------------------------------------------------------------------------
1 | # Guía de Usuario: CTF Write-up Builder
2 |
3 | ## ¿Qué es CTF Write-up Builder?
4 |
5 | CTF Write-up Builder es una aplicación web que te permite crear, organizar y exportar write-ups (resoluciones) de máquinas y retos de CTF de forma profesional, rápida y segura.
6 | Soporta español e inglés, y está diseñada para la comunidad de ciberseguridad.
7 |
8 | ---
9 |
10 | ## Índice
11 |
12 | 1. [Primeros pasos](#primeros-pasos)
13 | 2. [Estructura de la aplicación](#estructura-de-la-aplicación)
14 | 3. [Secciones principales y su uso](#secciones-principales-y-su-uso)
15 | 4. [Importar y exportar write-ups](#importar-y-exportar-write-ups)
16 | 5. [Uso de la IA (OpenAI/Gemini)](#uso-de-la-ia-openai-gemini)
17 | 6. [Personalización y plantillas](#personalización-y-plantillas)
18 | 7. [Gestión de idiomas](#gestión-de-idiomas)
19 | 8. [Consejos de seguridad y privacidad](#consejos-de-seguridad-y-privacidad)
20 | 9. [Preguntas frecuentes](#preguntas-frecuentes)
21 |
22 | ---
23 |
24 | ## Primeros pasos
25 |
26 | 1. **Accede a la app:**
27 | Abre la URL de la aplicación en tu navegador (por ejemplo, la de Vercel).
28 |
29 | 2. **Selecciona el idioma:**
30 | Puedes elegir entre español e inglés desde el menú superior o de configuración.
31 |
32 | 3. **Configura tu API Key (opcional):**
33 | Si quieres usar la IA para sugerencias automáticas, configura tu clave de OpenAI o Gemini en la sección de configuración.
34 |
35 | ---
36 |
37 | ## Estructura de la aplicación
38 |
39 | - **Barra superior:**
40 | Acceso a idioma, configuración, importación/exportación, y ayuda.
41 |
42 | - **Panel principal:**
43 | Aquí editas tu write-up, añades secciones, imágenes, y gestionas el contenido.
44 |
45 | - **Vista previa:**
46 | Puedes ver cómo quedará tu write-up final en tiempo real.
47 |
48 | ---
49 |
50 | ## Secciones principales y su uso
51 |
52 | ### 1. **Título y Metadatos**
53 |
54 | - **Título:**
55 | Escribe el nombre de la máquina o reto. Ejemplo: `HTB - Blue`
56 | - **Autor:**
57 | Tu nombre o alias.
58 | - **Fecha:**
59 | La fecha se genera automáticamente y **no se puede modificar**. Siempre refleja la fecha actual.
60 | - **Dificultad, sistema operativo:**
61 | Completa estos campos para un write-up profesional.
62 |
63 | ### 2. **Secciones del Write-up**
64 |
65 | La app incluye plantillas sugeridas (Reconocimiento, Explotación, Post-Explotación, etc.) y puedes añadir, eliminar o reordenar secciones a tu gusto.
66 |
67 | - **Añadir sección:**
68 | Haz clic en "Añadir sección" y elige un nombre.
69 | - **Editar sección:**
70 | Haz clic en el título o contenido para modificarlo.
71 | - **Reordenar secciones:**
72 | Arrastra las secciones de usuario para cambiar el orden (las plantillas fijas no se pueden mover).
73 | - **Eliminar sección:**
74 | Haz clic en el icono de papelera en la sección correspondiente.
75 |
76 | ### 3. **Imágenes y archivos**
77 |
78 | - **Añadir imagen:**
79 | **IMPORTANTE:** Solo puedes añadir imágenes a secciones que hayas editado o creado. Si no has modificado una plantilla sugerida o añadido una sección, no podrás subir imágenes ni modificar la estructura.
80 | - **Visualización:**
81 | Las imágenes se muestran en la vista previa y en la exportación.
82 |
83 | ### 4. **Notas y consejos**
84 |
85 | - Usa las secciones para documentar cada fase del reto.
86 | - Puedes copiar/pegar comandos, salidas de terminal, y resultados.
87 |
88 | ---
89 |
90 | ## Importar y exportar write-ups
91 |
92 | ### **Importar**
93 |
94 | - **Desde JSON:**
95 | Importa un write-up previamente exportado desde la app.
96 | - **Desde Markdown:**
97 | Importa write-ups en formato Markdown (.md). La app intentará mapear los encabezados y secciones automáticamente.
98 | - **Opciones de importación:**
99 | Puedes elegir entre fusionar con tu write-up actual o reemplazarlo completamente.
100 |
101 | ### **Exportar**
102 |
103 | - **A PDF:**
104 | Exporta tu write-up en formato PDF listo para compartir o entregar.
105 | - **A JSON:**
106 | Guarda tu progreso para continuar editando más tarde.
107 | - **A Markdown:**
108 | Exporta el contenido en formato Markdown para compartir en foros o repositorios.
109 |
110 | ---
111 |
112 | ## Uso de la IA (OpenAI/Gemini)
113 |
114 | - **Configura tu API Key:**
115 | Ve a configuración y añade tu clave de OpenAI o Gemini.
116 | - **Sugerencias automáticas:**
117 | En cada sección puedes pedir a la IA que te ayude a redactar, resumir o mejorar el texto.
118 | - **Privacidad:**
119 | Las claves se almacenan localmente y nunca se envían a servidores externos.
120 |
121 | ---
122 |
123 | ## Personalización y plantillas
124 |
125 | - **Secciones sugeridas:**
126 | Siempre tendrás plantillas de secciones recomendadas (Recon, Exploit, etc.).
127 | - **Crea tus propias plantillas:**
128 | Puedes añadir y guardar secciones personalizadas para futuros write-ups.
129 |
130 | ---
131 |
132 | ## Gestión de idiomas
133 |
134 | - **Cambia de idioma en cualquier momento:**
135 | El contenido de la interfaz y las plantillas se adaptan automáticamente.
136 | - **Advertencia:**
137 | Cambiar de idioma puede resetear el write-up actual (se muestra un aviso de confirmación).
138 |
139 | ---
140 |
141 | ## Consejos de seguridad y privacidad
142 |
143 | - **Todo el contenido se guarda localmente en tu navegador.**
144 | - **No se envía información a servidores externos, salvo que uses la IA.**
145 | - **Las claves API están protegidas y solo se usan en tu dispositivo.**
146 | - **No subas información sensible o privada si vas a compartir el write-up.**
147 |
148 | ---
149 |
150 | ## Preguntas frecuentes
151 |
152 | **¿Puedo usar la app sin conexión?**
153 | Sí, pero algunas funciones (como la IA) requieren internet.
154 |
155 | **¿Puedo compartir mis write-ups?**
156 | Sí, exporta a PDF, Markdown o JSON y compártelos como prefieras.
157 |
158 | **¿Qué pasa si cierro la app?**
159 | Tu progreso se guarda localmente, pero exporta regularmente para evitar pérdidas.
160 |
161 | **¿Puedo colaborar con otros?**
162 | Por ahora, la edición es individual, pero puedes compartir archivos exportados.
163 |
164 | ---
165 |
166 | ## Soporte y contacto
167 |
168 | - **¿Tienes dudas o sugerencias?**
169 | Consulta la documentación, revisa los archivos de ayuda, o contacta al equipo en:
170 | **writeup_builder@proton.me**
171 |
172 | ---
173 |
174 | ¿Necesitas la guía en inglés o un PDF/manual visual? ¡Dímelo y lo preparo!
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | 🇪🇸 Leer en Español
3 |
4 |
5 | # CTF Write-up Builder
6 |
7 | [](https://nextjs.org/)
8 | [](https://reactjs.org/)
9 | [](https://www.typescriptlang.org/)
10 | [](https://github.com/ilanami/ctf_writeup_builder)
11 | [](https://opensource.org/licenses/MIT)
12 |
13 | > **A modern and private application for creating, organizing and exporting CTF write-ups with AI assistance**
14 |
15 | Designed by and for the cybersecurity community, CTF Write-up Builder allows you to document your CTFs professionally with Markdown support, automatic screenshots, AI content generation, and multiple export formats.
16 |
17 | ## 🌐 Live Demo
18 |
19 | **[🚀 Try the application here](https://ctf-writeup-builder.vercel.app/)**
20 |
21 | *No registration, no tracking, no limits. Your privacy is our priority.*
22 |
23 | ## 📸 Screenshots
24 |
25 | | Main View | AI Editor | Export |
26 | |-----------|-----------|---------|
27 | |  |  |  |
28 |
29 | ## ✨ Key Features
30 |
31 | ### 🤖 **Intelligent AI Generation**
32 | - **Google Gemini** and **OpenAI ChatGPT** integrated
33 | - Generate specific content for each section
34 | - Automated vulnerability analysis
35 | - Contextual tool suggestions
36 |
37 | ### 📝 **Advanced Editor**
38 | - Native **Markdown** with real-time preview
39 | - Integrated **screenshots** per section
40 | - Predefined **templates** for different CTF types
41 | - Customizable **tags** for organization
42 |
43 | ### 📄 **Multiple Export Formats**
44 | - Professional **PDF** with optimized formatting
45 | - Standard **Markdown** for GitHub/GitLab
46 | - **JSON** for backup and collaboration
47 |
48 | ### 🌐 **Multi-language**
49 | - Complete **Spanish** and **English**
50 | - Adaptive interface by region
51 | - Localized AI prompts
52 |
53 | ### 🛡️ **Privacy and Security**
54 | - **100% local** - No external servers
55 | - **Encrypted API keys** stored locally
56 | - **Open source** - Fully audited
57 | - **No tracking** or telemetry
58 |
59 | ### 📱 **User Experience**
60 | - **Responsive design** - Works on mobile and desktop
61 | - Professional **hacker theme**
62 | - **Auto-save** to prevent data loss
63 |
64 | ## 🚀 Installation
65 |
66 | ### Prerequisites
67 | - **Node.js** 18.0 or higher
68 | - **npm** or **yarn**
69 |
70 | ### Local Installation
71 |
72 | ```bash
73 | # Clone the repository
74 | git clone https://github.com/ilanami/ctf_writeup_builder.git
75 |
76 | # Navigate to directory
77 | cd ctf_writeup_builder
78 |
79 | # Install dependencies
80 | npm install
81 |
82 | # Run in development mode
83 | npm run dev
84 | ```
85 |
86 | The application will be available at `http://localhost:3000`
87 |
88 | ### Production Build
89 |
90 | ```bash
91 | # Create optimized build
92 | npm run build
93 |
94 | # Run in production
95 | npm start
96 | ```
97 |
98 | ## 💡 How to Use
99 |
100 | ### 1. **Configure AI (Optional)**
101 | - Click **"API Key"** in the top bar
102 | - Choose between **Google Gemini** or **OpenAI**
103 | - Enter your personal API key
104 | - [📖 How to get API Keys](#-api-configuration)
105 |
106 | ### 2. **Create Write-up**
107 | - Click **"New"** to start
108 | - Fill in basic information (title, difficulty, etc.)
109 | - Add sections according to your methodology
110 |
111 | ### 3. **Generate Content with AI**
112 | - In each section, click **"Generate with AI"**
113 | - Briefly describe what you found
114 | - AI will generate professional content
115 |
116 | ### 4. **Add Screenshots**
117 | - Use **"Add Screenshot"** in each section
118 | - Drag and drop images
119 | - Screenshots are automatically included in exports
120 |
121 | ### 5. **Export**
122 | - **PDF** for professional reports
123 | - **Markdown** for documentation
124 | - **JSON** for backup/collaboration
125 |
126 | ## 🔑 API Configuration
127 |
128 | ### Google Gemini (Recommended - Free)
129 | 1. Go to [Google AI Studio](https://makersuite.google.com/app/apikey)
130 | 2. Create a new API Key
131 | 3. Copy it to the app configuration
132 |
133 | ### OpenAI ChatGPT
134 | 1. Go to [OpenAI Platform](https://platform.openai.com/api-keys)
135 | 2. Create a new API Key (starts with `sk-`)
136 | 3. Copy it to the app configuration
137 |
138 | > 🔒 **Security**: Your API keys are stored encoded locally. They are never sent to external servers except to AI providers for content generation.
139 |
140 | ## 🏗️ Technical Architecture
141 |
142 | ### Technology Stack
143 | - **Frontend**: Next.js 15, React 18, TypeScript
144 | - **Styling**: Tailwind CSS, CSS Modules
145 | - **State**: React Context + useReducer
146 | - **AI**: Google Gemini & OpenAI APIs
147 | - **Security**: DOMPurify, Input sanitization
148 | - **Performance**: React.memo, useCallback optimizations
149 |
150 | ### Project Structure
151 | ```
152 | src/
153 | ├── app/ # Next.js App Router
154 | ├── components/ # Reusable React components
155 | ├── contexts/ # Global state (Context API)
156 | ├── utils/ # Utilities and helpers
157 | ├── ai/ # AI API integrations
158 | └── types/ # TypeScript definitions
159 | ```
160 |
161 | ## 🛡️ Security
162 |
163 | This application has been fully audited for security:
164 |
165 | - ✅ **XSS Prevention** - DOMPurify on all dynamic HTML
166 | - ✅ **Input Sanitization** - Validation on all inputs
167 | - ✅ **API Security** - Keys encoded locally
168 | - ✅ **Dependency Audit** - No known vulnerabilities
169 | - ✅ **OWASP Compliance** - Best practices implemented
170 |
171 | See [SECURITY.md](SECURITY.md) for complete details.
172 |
173 | ## 🌍 Multi-language
174 |
175 | Supported languages:
176 | - 🇪🇸 **Español** (Spain/Latin America)
177 | - 🇺🇸 **English** (US/International)
178 |
179 | Want to add your language? [Contribute here](#-contributing)
180 |
181 | ## 📋 Roadmap
182 |
183 | ### v1.1 - Performance Plus
184 | - [ ] Complete lazy loading
185 | - [ ] Virtual scrolling for large lists
186 | - [ ] Bundle size optimization
187 |
188 | ### v1.2 - UX Enhancements
189 | - [ ] More CTF templates
190 | - [ ] Keyboard shortcuts
191 | - [ ] Drag & drop to reorganize sections
192 |
193 | ### v1.3 - Collaboration
194 | - [ ] Export to more formats (DOCX, HTML)
195 | - [ ] Git integration
196 | - [ ] Basic collaborative mode
197 |
198 | ### v1.4 - Advanced Features
199 | - [ ] Plugin system
200 | - [ ] Custom AI prompts
201 | - [ ] CTF platform integrations
202 |
203 | ## 🤝 Contributing
204 |
205 | Contributions are welcome! This application is made by and for the CTF community.
206 |
207 | ### Ways to Contribute
208 | - 🐛 **Report bugs** in [Issues](https://github.com/ilanami/ctf_writeup_builder/issues)
209 | - 💡 **Suggest features**
210 | - 🌍 **Translate** to new languages
211 | - 🔧 **Submit** Pull Requests
212 | - ⭐ **Star** the project
213 |
214 | ### Local Development
215 | ```bash
216 | # Fork the repository
217 | # Clone your fork
218 | git clone https://github.com/YOUR-USERNAME/ctf_writeup_builder.git
219 |
220 | # Create branch for your feature
221 | git checkout -b feature/new-functionality
222 |
223 | # Make changes and commit
224 | git commit -m "feat: add new functionality"
225 |
226 | # Push and create Pull Request
227 | git push origin feature/new-functionality
228 | ```
229 |
230 | ## 🎁 Support my Projects and Tools
231 |
232 | If you've enjoyed my projects and tools and found them useful, consider buying me a coffee or making a donation as a thank you.
233 |
234 | It's not mandatory, but it would help me tremendously to continue creating tools like this and to pay for the cybersecurity certifications I want to obtain.
235 |
236 | Thank you very much for your support!
237 |
238 | [](https://www.paypal.me/1511amff)
239 | [](https://buymeacoffee.com/ilanami)
240 |
241 | ## 📞 Support and Contact
242 |
243 | ### 🐛 Report Issues
244 | - **GitHub Issues**: [Create new issue](https://github.com/ilanami/ctf_writeup_builder/issues/new)
245 | - **Email**: writeup_builder@proton.me
246 |
247 | ### 📧 Direct Contact
248 | For general inquiries, collaborations or proposals:
249 | **writeup_builder@proton.me**
250 |
251 | ## 📄 License
252 |
253 | This project is under the MIT license. See [LICENSE](LICENSE) for details.
254 |
255 | ## ⚠️ Note about Content Security Policy (CSP) and 'unsafe-eval'
256 |
257 | To ensure all features work correctly in development mode, the Content Security Policy (CSP) allows 'unsafe-eval' **only in development**. This is required by some dependencies for local development.
258 |
259 | **In production** (e.g., on Vercel), the CSP configuration **does not include** 'unsafe-eval', so the application is secure and compliant with platform security standards.
260 |
261 | **Do not modify the CSP to allow 'unsafe-eval' in production.**
262 | The current configuration manages this automatically depending on the environment.
263 |
264 | #### If you have issues with buttons or features not working in development:
265 |
266 | 1. Open the `next.config.mjs` file at the root of the project.
267 | 2. Make sure the CSP line looks like this:
268 | ```js
269 | {
270 | key: 'Content-Security-Policy',
271 | value: process.env.NODE_ENV === 'development'
272 | ? "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';"
273 | : "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';"
274 | },
275 | ```
276 | 3. Save the file and restart the development server:
277 | ```bash
278 | npm run dev
279 | ```
280 | 4. Reload the page in your browser with `Ctrl + F5`.
281 |
282 | **In production, 'unsafe-eval' is NOT allowed and the app is secure.**
283 |
284 | ## 🙏 Acknowledgments
285 |
286 | Thanks to all CTF players, open source contributors and the cybersecurity community that made this project possible.
287 |
288 | ### Technologies Used
289 | - [Next.js](https://nextjs.org/) - React Framework
290 | - [Tailwind CSS](https://tailwindcss.com/) - Styling
291 | - [Google Gemini](https://gemini.google.com/) - AI for generation
292 | - [OpenAI](https://openai.com/) - Alternative AI
293 | - [DOMPurify](https://github.com/cure53/DOMPurify) - XSS Sanitization
294 |
295 | ---
296 |
297 |
298 |
299 | **⭐ If this project helps you, consider giving it a star ⭐**
300 |
301 | **Made with ❤️ for the CTF community**
302 |
303 | [🚀 Try Application](https://ctf-writeup-builder.vercel.app) • [📖 Documentation](https://github.com/ilanami/ctf_writeup_builder/wiki) • [🐛 Report Bug](https://github.com/ilanami/ctf_writeup_builder/issues)
304 |
305 |
306 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 | We support the latest major and minor versions of CTF Write-up Builder. Please ensure you are using the most recent release for the best security and features.
5 |
6 | | Version | Supported |
7 | | ------- | ----------------- |
8 | | Latest | :white_check_mark:|
9 | | Older | :x: |
10 |
11 | ## Reporting a Vulnerability
12 | If you discover a security vulnerability, please report it responsibly by emailing: **writeup_builder@proton.me**
13 |
14 | - Do **not** create public GitHub issues for security problems.
15 | - We aim to respond within 48 hours and resolve critical issues as soon as possible.
16 |
17 | ## Security Best Practices
18 | - All user input is sanitized using [DOMPurify](https://github.com/cure53/DOMPurify) to prevent XSS.
19 | - The app is 100% client-side; no sensitive data is sent to external servers (except for AI API requests, which go directly to the provider).
20 | - API keys are stored locally in Base64 (not secure encryption, but never sent to our servers).
21 | - We recommend using unique API keys and never sharing them.
22 | - Always keep your browser and OS updated.
23 |
24 | ## Responsible Disclosure Policy
25 | We encourage responsible disclosure of security issues. Please:
26 | - Report vulnerabilities privately to the email above.
27 | - Allow us reasonable time to investigate and fix before public disclosure.
28 | - Provide clear steps to reproduce the issue.
29 |
30 | ## Security Context
31 | - The app has been security-audited and maintains an A/A+ rating on securityheaders.com.
32 | - No XSS vulnerabilities are present; all Markdown and HTML rendering is sanitized.
33 | - No hardcoded secrets or credentials in the codebase.
34 | - CORS and security headers are enforced in production.
35 |
36 | Thank you for helping keep CTF Write-up Builder secure for the global CTF community!
--------------------------------------------------------------------------------
/SECURITY_ES.md:
--------------------------------------------------------------------------------
1 | # Política de Seguridad
2 |
3 | ## Versiones Soportadas
4 | Solo se brinda soporte a la última versión principal y secundaria de CTF Write-up Builder. Por favor, utiliza siempre la versión más reciente para garantizar la mejor seguridad y experiencia.
5 |
6 | | Versión | Soportada |
7 | | ------- | ------------------- |
8 | | Última | :white_check_mark: |
9 | | Anteriores | :x: |
10 |
11 | ## Reporte de Vulnerabilidades
12 | Si detectas una vulnerabilidad de seguridad, repórtala de forma responsable escribiendo a: **writeup_builder@proton.me**
13 |
14 | - **No** crees issues públicos en GitHub para problemas de seguridad.
15 | - Nos comprometemos a responder en un plazo de 48 horas y resolver los problemas críticos lo antes posible.
16 |
17 | ## Buenas Prácticas de Seguridad
18 | - Todo input de usuario se sanitiza usando [DOMPurify](https://github.com/cure53/DOMPurify) para prevenir XSS.
19 | - La app es 100% cliente; ningún dato sensible se envía a servidores externos (excepto solicitudes de IA, que van directo al proveedor).
20 | - Las API keys se almacenan localmente en Base64 (no es cifrado seguro, pero nunca se envían a nuestros servidores).
21 | - Recomendamos usar API keys únicas y no compartirlas.
22 | - Mantén tu navegador y sistema operativo actualizados.
23 |
24 | ## Política de Divulgación Responsable
25 | Fomentamos la divulgación responsable de vulnerabilidades:
26 | - Reporta vulnerabilidades de forma privada al correo indicado.
27 | - Permite un tiempo razonable para investigar y corregir antes de divulgar públicamente.
28 | - Proporciona pasos claros para reproducir el problema.
29 |
30 | ## Contexto de Seguridad
31 | - La app ha sido auditada y mantiene una calificación A/A+ en securityheaders.com.
32 | - No existen vulnerabilidades XSS; todo el renderizado de Markdown y HTML es seguro.
33 | - No hay secretos ni credenciales hardcodeados en el código.
34 | - CORS y headers de seguridad están activos en producción.
35 |
36 | ¡Gracias por ayudar a mantener CTF Write-up Builder seguro para la comunidad CTF hispanohablante!
--------------------------------------------------------------------------------
/USER_GUIDE.md:
--------------------------------------------------------------------------------
1 | # User Guide: CTF Write-up Builder
2 |
3 | ## What is CTF Write-up Builder?
4 |
5 | CTF Write-up Builder is a web application that allows you to create, organize, and export write-ups (solutions) for CTF machines and challenges in a professional, fast, and secure way.
6 | It supports both English and Spanish, and is designed for the cybersecurity community.
7 |
8 | ---
9 |
10 | ## Table of Contents
11 |
12 | 1. [Getting Started](#getting-started)
13 | 2. [Application Structure](#application-structure)
14 | 3. [Main Sections and Usage](#main-sections-and-usage)
15 | 4. [Import and Export Write-ups](#import-and-export-write-ups)
16 | 5. [Using AI (OpenAI/Gemini)](#using-ai-openai-gemini)
17 | 6. [Customization and Templates](#customization-and-templates)
18 | 7. [Language Management](#language-management)
19 | 8. [Security and Privacy Tips](#security-and-privacy-tips)
20 | 9. [Frequently Asked Questions](#frequently-asked-questions)
21 |
22 | ---
23 |
24 | ## Getting Started
25 |
26 | 1. **Access the app:**
27 | Open the application URL in your browser (e.g., the Vercel deployment).
28 |
29 | 2. **Select language:**
30 | Choose between English and Spanish from the top menu or settings.
31 |
32 | 3. **Configure API Key (optional):**
33 | If you want to use AI for automatic suggestions, configure your OpenAI or Gemini key in the settings section.
34 |
35 | ---
36 |
37 | ## Application Structure
38 |
39 | - **Top Bar:**
40 | Access to language, settings, import/export, and help.
41 |
42 | - **Main Panel:**
43 | Here you edit your write-up, add sections, images, and manage content.
44 |
45 | - **Preview:**
46 | You can see how your final write-up will look in real-time.
47 |
48 | ---
49 |
50 | ## Main Sections and Usage
51 |
52 | ### 1. **Title and Metadata**
53 |
54 | - **Title:**
55 | Write the name of the machine or challenge. Example: `HTB - Blue`
56 | - **Author:**
57 | Your name or alias.
58 | - **Date:**
59 | The date is automatically generated and **cannot be modified**. It always reflects the current date.
60 | - **Difficulty, Operating System:**
61 | Complete these fields for a professional write-up.
62 |
63 | ### 2. **Write-up Sections**
64 |
65 | The app includes suggested templates (Reconnaissance, Exploitation, Post-Exploitation, etc.) and you can add, delete, or reorder sections as needed.
66 |
67 | - **Add Section:**
68 | Click "Add Section" and choose a name.
69 | - **Edit Section:**
70 | Click on the title or content to modify it.
71 | - **Reorder Sections:**
72 | Drag user sections to change their order (fixed templates cannot be moved).
73 | - **Delete Section:**
74 | Click the trash icon in the corresponding section.
75 |
76 | ### 3. **Images and Files**
77 |
78 | - **Add Image:**
79 | **IMPORTANT:** You can only add images to sections that you have edited or created. If you haven't modified a suggested template or added a section, you won't be able to upload images or modify the structure.
80 | - **Visualization:**
81 | Images are displayed in the preview and export.
82 |
83 | ### 4. **Notes and Tips**
84 |
85 | - Use sections to document each phase of the challenge.
86 | - You can copy/paste commands, terminal outputs, and results.
87 |
88 | ---
89 |
90 | ## Import and Export Write-ups
91 |
92 | ### **Import**
93 |
94 | - **From JSON:**
95 | Import a previously exported write-up from the app.
96 | - **From Markdown:**
97 | Import write-ups in Markdown format (.md). The app will try to map headers and sections automatically.
98 | - **Import Options:**
99 | You can choose to merge with your current write-up or replace it completely.
100 |
101 | ### **Export**
102 |
103 | - **To PDF:**
104 | Export your write-up in PDF format ready to share or submit.
105 | - **To JSON:**
106 | Save your progress to continue editing later.
107 | - **To Markdown:**
108 | Export content in Markdown format to share in forums or repositories.
109 |
110 | ---
111 |
112 | ## Using AI (OpenAI/Gemini)
113 |
114 | - **Configure API Key:**
115 | Go to settings and add your OpenAI or Gemini key.
116 | - **Automatic Suggestions:**
117 | In each section, you can ask the AI to help you write, summarize, or improve the text.
118 | - **Privacy:**
119 | Keys are stored locally and are never sent to external servers.
120 |
121 | ---
122 |
123 | ## Customization and Templates
124 |
125 | - **Suggested Sections:**
126 | You'll always have recommended section templates (Recon, Exploit, etc.).
127 | - **Create Your Own Templates:**
128 | You can add and save custom sections for future write-ups.
129 |
130 | ---
131 |
132 | ## Language Management
133 |
134 | - **Change Language Anytime:**
135 | Interface content and templates adapt automatically.
136 | - **Warning:**
137 | Changing language may reset the current write-up (a confirmation warning is shown).
138 |
139 | ---
140 |
141 | ## Security and Privacy Tips
142 |
143 | - **All content is saved locally in your browser.**
144 | - **No information is sent to external servers, except when using AI.**
145 | - **API keys are protected and only used on your device.**
146 | - **Don't upload sensitive or private information if you plan to share the write-up.**
147 |
148 | ---
149 |
150 | ## Frequently Asked Questions
151 |
152 | **Can I use the app offline?**
153 | Yes, but some features (like AI) require internet.
154 |
155 | **Can I share my write-ups?**
156 | Yes, export to PDF, Markdown, or JSON and share them as you prefer.
157 |
158 | **What happens if I close the app?**
159 | Your progress is saved locally, but export regularly to avoid losses.
160 |
161 | **Can I collaborate with others?**
162 | For now, editing is individual, but you can share exported files.
163 |
164 | ---
165 |
166 | ## Support and Contact
167 |
168 | - **Questions or suggestions?**
169 | Check the documentation, review help files, or contact the team at:
170 | **writeup_builder@proton.me**
171 |
172 | ---
173 |
174 | Need the guide in Spanish or a PDF/visual manual? Just let me know!
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
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 | }
--------------------------------------------------------------------------------
/ctf_writeup_builder-main.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "."
5 | }
6 | ]
7 | }
--------------------------------------------------------------------------------
/docs/blueprint.md:
--------------------------------------------------------------------------------
1 | # **App Name**: WriteUp Terminal
2 |
3 | ## Core Features:
4 |
5 | - Write-up editor: A terminal-inspired interface that enables the user to write and structure CTF write-ups. The app will allow import and export of JSON files and data will persist locally.
6 | - Customizable Sections: User can customize write-up sections. Add/remove/reorder sections to adapt the editor to specific challenges.
7 | - Live Preview: Preview the generated write-up as it is built.
8 | - Export: Allows exporting to a PDF for wider distribution, or as markdown.
9 |
10 | ## Style Guidelines:
11 |
12 | - Dark background (#0A0A0A) to simulate a terminal interface.
13 | - Primary color: Neon green (#00FF00) for text to maintain the hacker aesthetic. Should only be used for key pieces of information or for short highlights.
14 | - Accent color: Cyan (#00FFFF) to highlight interactive elements, buttons, and important UI affordances.
15 | - Use monospace fonts to mimic terminal output.
16 | - Simple line icons for actions and section types, complementing the terminal aesthetic.
17 | - A layout with a sidebar for general information and settings, a central panel for the write-up sections, and a preview panel.
--------------------------------------------------------------------------------
/docs/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilanami/ctf_writeup_builder/09d0939aed968f56dcd6fd568b16d7560ebd947a/docs/logo.png
--------------------------------------------------------------------------------
/docs/screenshots/ai-editor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilanami/ctf_writeup_builder/09d0939aed968f56dcd6fd568b16d7560ebd947a/docs/screenshots/ai-editor.png
--------------------------------------------------------------------------------
/docs/screenshots/export.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilanami/ctf_writeup_builder/09d0939aed968f56dcd6fd568b16d7560ebd947a/docs/screenshots/export.png
--------------------------------------------------------------------------------
/docs/screenshots/main-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilanami/ctf_writeup_builder/09d0939aed968f56dcd6fd568b16d7560ebd947a/docs/screenshots/main-view.png
--------------------------------------------------------------------------------
/favicon.ico.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilanami/ctf_writeup_builder/09d0939aed968f56dcd6fd568b16d7560ebd947a/favicon.ico.png
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | async headers() {
4 | const isDev = process.env.NODE_ENV === 'development';
5 |
6 | if (isDev) {
7 | // Desarrollo: CSP muy permisiva para que todo funcione
8 | console.log('🔧 Desarrollo: CSP permisiva');
9 | return [
10 | {
11 | source: '/(.*)',
12 | headers: [
13 | {
14 | key: 'Content-Security-Policy',
15 | value: [
16 | "default-src 'self'",
17 | "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
18 | "style-src 'self' 'unsafe-inline'",
19 | "img-src 'self' data: https: blob:",
20 | "connect-src 'self' https:",
21 | "font-src 'self' data:",
22 | "object-src 'none'",
23 | "media-src 'self'",
24 | "frame-src 'self'",
25 | "worker-src 'self' blob:",
26 | "child-src 'self' blob:",
27 | "base-uri 'self'"
28 | ].join('; ')
29 | }
30 | ]
31 | }
32 | ];
33 | }
34 |
35 | // Producción: CSP A+ pero funcional
36 | console.log('🔒 Producción: CSP A+ funcional');
37 | return [
38 | {
39 | source: '/(.*)',
40 | headers: [
41 | {
42 | key: 'Content-Security-Policy',
43 | value: [
44 | "default-src 'self'",
45 | // CRÍTICO: Permitir Next.js chunks y React
46 | "script-src 'self' 'unsafe-inline'", // Necesario para Next.js
47 | "style-src 'self' 'unsafe-inline'", // Necesario para Tailwind
48 | "img-src 'self' data: https: blob:",
49 | "connect-src 'self' https:", // Para API calls
50 | "font-src 'self' data:",
51 | "object-src 'none'",
52 | "media-src 'self'",
53 | "frame-src 'self'",
54 | "worker-src 'self' blob:", // Para Web Workers si los usas
55 | "child-src 'self' blob:",
56 | "base-uri 'self'",
57 | "form-action 'self'",
58 | "frame-ancestors 'none'"
59 | ].join('; ')
60 | },
61 | // Headers A+ que SÍ funcionan
62 | {
63 | key: 'X-Frame-Options',
64 | value: 'DENY'
65 | },
66 | {
67 | key: 'X-Content-Type-Options',
68 | value: 'nosniff'
69 | },
70 | {
71 | key: 'Referrer-Policy',
72 | value: 'strict-origin-when-cross-origin'
73 | },
74 | {
75 | key: 'Permissions-Policy',
76 | value: 'camera=(), microphone=(), geolocation=()'
77 | }
78 | // REMOVIDOS temporalmente los que causan problemas:
79 | // - Cross-Origin-Embedder-Policy
80 | // - Cross-Origin-Opener-Policy
81 | // - Strict-Transport-Security (Vercel lo maneja)
82 | ]
83 | }
84 | ];
85 | },
86 |
87 | poweredByHeader: false,
88 |
89 | experimental: {
90 | serverActions: {
91 | bodySizeLimit: '2mb'
92 | }
93 | }
94 | };
95 |
96 | export default nextConfig;
97 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextn",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev -p 3000",
7 | "genkit:dev": "genkit start -- tsx src/ai/dev.ts",
8 | "genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts",
9 | "build": "next build",
10 | "start": "next start",
11 | "lint": "next lint",
12 | "typecheck": "tsc --noEmit"
13 | },
14 | "dependencies": {
15 | "@genkit-ai/googleai": "^1.8.0",
16 | "@radix-ui/react-accordion": "^1.2.3",
17 | "@radix-ui/react-alert-dialog": "^1.1.6",
18 | "@radix-ui/react-avatar": "^1.1.3",
19 | "@radix-ui/react-checkbox": "^1.1.4",
20 | "@radix-ui/react-dialog": "^1.1.6",
21 | "@radix-ui/react-dropdown-menu": "^2.1.6",
22 | "@radix-ui/react-label": "^2.1.2",
23 | "@radix-ui/react-menubar": "^1.1.6",
24 | "@radix-ui/react-popover": "^1.1.6",
25 | "@radix-ui/react-progress": "^1.1.2",
26 | "@radix-ui/react-radio-group": "^1.2.3",
27 | "@radix-ui/react-scroll-area": "^1.2.3",
28 | "@radix-ui/react-select": "^2.1.6",
29 | "@radix-ui/react-separator": "^1.1.2",
30 | "@radix-ui/react-slider": "^1.2.3",
31 | "@radix-ui/react-slot": "^1.1.2",
32 | "@radix-ui/react-switch": "^1.1.3",
33 | "@radix-ui/react-tabs": "^1.1.3",
34 | "@radix-ui/react-toast": "^1.2.6",
35 | "@radix-ui/react-tooltip": "^1.1.8",
36 | "@tiptap/extension-link": "^2.12.0",
37 | "@tiptap/extension-placeholder": "^2.12.0",
38 | "@tiptap/extension-text-align": "^2.12.0",
39 | "@tiptap/extension-underline": "^2.12.0",
40 | "@tiptap/react": "^2.12.0",
41 | "@tiptap/starter-kit": "^2.12.0",
42 | "@types/dompurify": "^3.0.5",
43 | "@vercel/analytics": "^1.5.0",
44 | "class-variance-authority": "^0.7.1",
45 | "clsx": "^2.1.1",
46 | "date-fns": "^3.6.0",
47 | "dompurify": "^3.2.6",
48 | "geist": "^1.3.0",
49 | "genkit": "^1.8.0",
50 | "lucide-react": "^0.475.0",
51 | "marked": "^13.0.2",
52 | "next": "^15.3.2",
53 | "next-international": "^1.2.5",
54 | "pdfjs-dist": "^4.10.38",
55 | "react": "^18.3.1",
56 | "react-beautiful-dnd": "^13.1.1",
57 | "react-day-picker": "^8.10.1",
58 | "react-dom": "^18.3.1",
59 | "react-hook-form": "^7.54.2",
60 | "react-markdown": "^9.0.1",
61 | "recharts": "^2.15.1",
62 | "remark-gfm": "^4.0.0",
63 | "tailwind-merge": "^3.0.1",
64 | "tailwindcss-animate": "^1.0.7",
65 | "uuid": "^10.0.0"
66 | },
67 | "devDependencies": {
68 | "@types/marked": "^6.0.0",
69 | "@types/react": "^18",
70 | "@types/react-dom": "^18",
71 | "@types/uuid": "^10.0.0",
72 | "genkit-cli": "^1.8.0",
73 | "tailwindcss": "^3.4.1",
74 | "typescript": "^5"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/src/ai/dev.ts:
--------------------------------------------------------------------------------
1 | // Flows will be imported for their side effects in this file.
2 | import '@/ai/flows/section-suggester-flow';
3 |
--------------------------------------------------------------------------------
/src/ai/flows/pdf-processor-flow.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 | /**
3 | * @fileOverview AI flow to process a PDF and extract structured content.
4 | *
5 | * - processPdf - Extracts structured sections (title, content as Markdown) from a PDF.
6 | * - PdfInput - Input type for the flow (PDF data URI).
7 | * - AiPdfParseOutput - Output type for the flow (array of parsed sections).
8 | */
9 |
10 | import { ai } from '@/ai/genkit';
11 | import { z } from 'genkit';
12 |
13 | export const PdfInputSchema = z.object({
14 | pdfDataUri: z
15 | .string()
16 | .describe(
17 | "The PDF file content as a data URI. Expected format: 'data:application/pdf;base64,'."
18 | ),
19 | });
20 | export type PdfInput = z.infer;
21 |
22 | const AiProcessedSectionSchema = z.object({
23 | title: z.string().describe('A concise title for the identified section.'),
24 | content: z.string().describe("The Markdown content for this section. IMPORTANT: If images were present in this section of the PDF, you MUST insert a placeholder in the Markdown text exactly where the image appeared. The placeholder format is: \"[IMAGEN: Provide a 1-2 sentence, human-readable description of the image's content and its relevance to the section. e.g., 'Nmap scan results showing open ports.' or 'Login page screenshot with SQL injection payload.']\". Do not attempt to reproduce or describe the image data itself, only insert this placeholder string."),
25 | });
26 |
27 | export const AiPdfParseOutputSchema = z.object({
28 | parsed_sections: z
29 | .array(AiProcessedSectionSchema)
30 | .describe('An array of sections extracted and structured from the PDF by the AI.'),
31 | });
32 | export type AiPdfParseOutput = z.infer;
33 |
34 |
35 | export async function processPdf(input: PdfInput): Promise {
36 | if (!input.pdfDataUri) {
37 | throw new Error('PDF data URI is required for AI processing.');
38 | }
39 | return pdfProcessorFlow(input);
40 | }
41 |
42 | const pdfProcessingPrompt = ai.definePrompt({
43 | name: 'pdfProcessingPrompt',
44 | input: { schema: PdfInputSchema },
45 | output: { schema: AiPdfParseOutputSchema },
46 | prompt: `You are an expert in analyzing technical documents, especially cybersecurity CTF (Capture The Flag) write-ups.
47 | Your task is to process the provided PDF content and extract its structure into logical sections.
48 |
49 | For the PDF provided via the 'pdfDataUri' input:
50 | 1. Identify the main sections or logical parts of the document. These could be chapters, major headings, or distinct topics.
51 | 2. For each section you identify:
52 | a. Extract or formulate a concise and descriptive title for that section.
53 | b. Extract the relevant textual content for that section and format it as well-structured Markdown. Ensure code blocks, lists, and other formatting are preserved or appropriately converted to Markdown.
54 | c. **Crucially**: If you encounter any images within a section of the PDF, you MUST insert a placeholder string in the Markdown content *exactly where the image appeared*. The placeholder format is: "[IMAGEN: Provide a 1-2 sentence, human-readable description of the image's content and its relevance or context within the section. For example: '[IMAGEN: Nmap scan results showing open ports 22, 80, and 443 on the target IP.]' or '[IMAGEN: Screenshot of the web application's dashboard after successful login.]']". Do NOT attempt to describe the image in prose outside this placeholder. Do NOT try to reproduce the image data itself.
55 | 3. Return the extracted information as a JSON object matching the output schema, containing an array called "parsed_sections". Each element in this array should be an object with "title" and "content" fields.
56 |
57 | If the PDF is unparsable, contains no discernible text, or is not a document format you can understand, return an empty "parsed_sections" array.
58 | Do not invent content. Only extract and structure what is present in the PDF.
59 | Focus on clear, well-formatted Markdown output for the content of each section.
60 | `,
61 | });
62 |
63 | const pdfProcessorFlow = ai.defineFlow(
64 | {
65 | name: 'pdfProcessorFlow',
66 | inputSchema: PdfInputSchema,
67 | outputSchema: AiPdfParseOutputSchema,
68 | },
69 | async (input) => {
70 | try {
71 | const { output } = await pdfProcessingPrompt(input);
72 | if (!output) {
73 | console.warn('AI PDF processing returned no output, returning empty sections.');
74 | return { parsed_sections: [] };
75 | }
76 | // Ensure parsed_sections is always an array, even if the AI fails to structure it correctly
77 | return { parsed_sections: Array.isArray(output.parsed_sections) ? output.parsed_sections : [] };
78 | } catch (error) {
79 | console.error('Error in pdfProcessorFlow during AI prompt execution:', error);
80 | // Return empty sections on error to allow fallback to pdfjs-dist
81 | return { parsed_sections: [] };
82 | }
83 | }
84 | );
85 |
--------------------------------------------------------------------------------
/src/ai/flows/section-suggester-flow.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 | /**
3 | * @fileOverview AI flow to suggest content for a CTF write-up section.
4 | *
5 | * - suggestSectionContent - Generates Markdown content for a given section title, type, and user prompt.
6 | * - SuggestSectionContentInput - Input type for the flow.
7 | * - SuggestSectionContentOutput - Output type for the flow.
8 | */
9 |
10 | import { ai } from '@/ai/genkit';
11 | import type { SectionType } from '@/lib/types';
12 | import { z } from 'genkit';
13 |
14 | const SuggestSectionContentInputSchema = z.object({
15 | sectionTitle: z.string().describe('The title of the section for which to generate content.'),
16 | sectionType: z.custom().describe('The type of the section (e.g., paso, pregunta, flag, notas).'),
17 | userPrompt: z.string().optional().describe('An optional user-provided prompt or keywords to guide content generation.'),
18 | });
19 | export type SuggestSectionContentInput = z.infer;
20 |
21 | const SuggestSectionContentOutputSchema = z.object({
22 | suggestedContent: z.string().describe('The AI-generated Markdown content for the section.'),
23 | });
24 | export type SuggestSectionContentOutput = z.infer;
25 |
26 | export async function suggestSectionContent(input: SuggestSectionContentInput): Promise {
27 | // Basic validation
28 | if (!input.sectionTitle || input.sectionTitle.trim() === "") {
29 | throw new Error("Section title cannot be empty for AI generation.");
30 | }
31 | return sectionSuggesterFlow(input);
32 | }
33 |
34 | const sectionContentPrompt = ai.definePrompt({
35 | name: 'sectionContentPrompt',
36 | input: { schema: SuggestSectionContentInputSchema },
37 | output: { schema: SuggestSectionContentOutputSchema },
38 | prompt: `You are an expert cybersecurity Capture The Flag (CTF) player and technical writer.
39 | Your task is to generate initial Markdown content for a specific section of a CTF write-up.
40 |
41 | Section Title: {{{sectionTitle}}}
42 | Section Type: {{{sectionType}}}
43 | {{#if userPrompt}}User's Focus/Keywords for this section: {{{userPrompt}}}{{/if}}
44 |
45 | Based on this information, provide a comprehensive and well-formatted Markdown draft for this section.
46 | - If the section type is 'paso' (step), describe common actions, tools, or commands relevant to the title and prompt.
47 | - If it's 'pregunta' (question), formulate a relevant question based on the title/prompt and provide a common or example answer.
48 | - If it's 'flag', describe how a flag might be typically found, formatted, or what it might represent in the context of the title/prompt.
49 | - If it's 'notas' (notes), provide general observations, tips, or further research points related to the title/prompt.
50 |
51 | Use Markdown effectively:
52 | - Employ headings (e.g., ## Sub-heading) if appropriate for structure.
53 | - Use code blocks (e.g., \`\`\`bash ... \`\`\`) for commands or code snippets.
54 | - Use lists (bulleted or numbered) for steps or enumerated items.
55 | - Emphasize key terms using **bold** or *italics*.
56 |
57 | Be concise but informative. Aim for a helpful starting point that the user can then elaborate on.
58 |
59 | Generated Markdown Content:
60 | `,
61 | });
62 |
63 | const sectionSuggesterFlow = ai.defineFlow(
64 | {
65 | name: 'sectionSuggesterFlow',
66 | inputSchema: SuggestSectionContentInputSchema,
67 | outputSchema: SuggestSectionContentOutputSchema,
68 | },
69 | async (input) => {
70 | const { output } = await sectionContentPrompt(input);
71 | if (!output) {
72 | // This case should ideally be handled by Genkit if the model fails to produce output matching the schema.
73 | // However, as a fallback:
74 | throw new Error('AI did not return an output matching the expected schema.');
75 | }
76 | return output;
77 | }
78 | );
79 |
--------------------------------------------------------------------------------
/src/ai/genkit.ts:
--------------------------------------------------------------------------------
1 | import {genkit} from 'genkit';
2 | import {googleAI} from '@genkit-ai/googleai';
3 |
4 | export const ai = genkit({
5 | plugins: [googleAI()],
6 | model: 'googleai/gemini-1.5-pro',
7 | });
8 |
--------------------------------------------------------------------------------
/src/app/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | // src/app/[locale]/layout.tsx
2 | import type { Metadata } from 'next';
3 | import { GeistSans } from 'geist/font/sans';
4 | import { GeistMono } from 'geist/font/mono';
5 | import '../globals.css';
6 | import { Toaster } from "@/components/ui/toaster";
7 | import { I18nProviderClient } from '@/locales/client';
8 | import type { ReactNode } from 'react';
9 | import { Analytics } from '@vercel/analytics/react';
10 |
11 | export const metadata: Metadata = {
12 | title: 'CTF Write-up Builder >_',
13 | description: 'CTF Write-up Builder - Create, manage, and export CTF reports with a hacker aesthetic.',
14 | };
15 |
16 |
17 | export default async function RootLayout({
18 | children,
19 | params
20 | }: Readonly<{
21 | children: ReactNode;
22 | params: Promise<{ locale: string }>;
23 | }>) {
24 | const { locale } = await params;
25 | console.log(`RootLayout rendering with locale: ${locale}`);
26 | return (
27 |
28 |
29 |
30 | {/* Eliminado script inline para máxima seguridad CSP */}
31 |
32 |
33 | {children}
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
42 | export async function generateStaticParams() {
43 | return [{ locale: 'en' }, { locale: 'es' }];
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/[locale]/page.tsx:
--------------------------------------------------------------------------------
1 | // src/app/[locale]/page.tsx
2 | import { WriteUpProvider } from '@/contexts/WriteUpContext';
3 | import { AppLayout } from '@/components/AppLayout';
4 |
5 | export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
6 | const { locale } = await params;
7 | return (
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/app/api/ai-generate/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 |
3 | export async function POST(request: NextRequest) {
4 | try {
5 | const { prompt, provider, apiKey } = await request.json();
6 |
7 | if (!prompt || !provider || !apiKey) {
8 | return NextResponse.json(
9 | { error: 'Faltan parámetros requeridos' },
10 | { status: 400 }
11 | );
12 | }
13 |
14 | let response: Response;
15 | let result: string = '';
16 |
17 | if (provider === 'openai') {
18 | response = await fetch('https://api.openai.com/v1/chat/completions', {
19 | method: 'POST',
20 | headers: {
21 | 'Authorization': `Bearer ${apiKey}`,
22 | 'Content-Type': 'application/json',
23 | },
24 | body: JSON.stringify({
25 | model: 'gpt-3.5-turbo',
26 | messages: [{ role: 'user', content: prompt }],
27 | max_tokens: 1000,
28 | }),
29 | });
30 |
31 | if (!response.ok) {
32 | const errorData = await response.json();
33 | throw new Error(`OpenAI API error: ${errorData.error?.message || 'Unknown error'}`);
34 | }
35 |
36 | const data = await response.json();
37 | result = data.choices[0]?.message?.content || '';
38 |
39 | } else if (provider === 'gemini') {
40 | response = await fetch(
41 | `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`,
42 | {
43 | method: 'POST',
44 | headers: {
45 | 'Content-Type': 'application/json',
46 | },
47 | body: JSON.stringify({
48 | contents: [{ parts: [{ text: prompt }] }],
49 | }),
50 | }
51 | );
52 |
53 | if (!response.ok) {
54 | const errorData = await response.json();
55 | throw new Error(`Gemini API error: ${errorData.error?.message || 'Unknown error'}`);
56 | }
57 |
58 | const data = await response.json();
59 | result = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
60 | }
61 |
62 | return NextResponse.json({ content: result });
63 |
64 | } catch (error: any) {
65 | console.error('AI Generation Error:', error);
66 | return NextResponse.json(
67 | { error: `No se pudo generar contenido: ${error.message}` },
68 | { status: 500 }
69 | );
70 | }
71 | }
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilanami/ctf_writeup_builder/09d0939aed968f56dcd6fd568b16d7560ebd947a/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 4%; /* #0a0a0a */
8 | --foreground: 120 100% 50%; /* #00ff00 Neon Green */
9 |
10 | --muted: 0 0% 14%; /* Darker gray for muted elements */
11 | --muted-foreground: 120 100% 35%; /* Dimmer Neon Green for muted text */
12 |
13 | --popover: 0 0% 6%; /* Slightly lighter than background for popovers */
14 | --popover-foreground: 120 100% 50%; /* Neon Green for popover text */
15 |
16 | --card: 0 0% 6%; /* Slightly lighter than background for cards */
17 | --card-foreground: 120 100% 50%; /* Neon Green for card text */
18 |
19 | --border: 150 50% 20%; /* Dark Green/Cyanish border */
20 | --input: 0 0% 10%; /* Background for input fields */
21 |
22 | --primary: 180 100% 50%; /* #00ffff Cyan for primary actions/buttons */
23 | --primary-foreground: 0 0% 0%; /* Black for text on primary elements for contrast */
24 |
25 | --secondary: 0 0% 12%; /* Dark gray for secondary elements */
26 | --secondary-foreground: 120 100% 45%; /* Slightly dimmer Neon Green for secondary text */
27 |
28 | --accent: 180 100% 60%; /* Lighter Cyan for accents like hover/focus */
29 | --accent-foreground: 0 0% 0%; /* Black for text on accent elements */
30 |
31 | --destructive: 0 84.2% 60.2%; /* Default red, can be themed later */
32 | --destructive-foreground: 0 0% 98%;
33 |
34 | --ring: 180 100% 50%; /* Cyan for focus rings */
35 |
36 | --radius: 0.5rem;
37 |
38 | /* Chart colors (can be adjusted for hacker theme if charts are used) */
39 | --chart-1: 120 100% 50%; /* Neon Green */
40 | --chart-2: 180 100% 50%; /* Cyan */
41 | --chart-3: 210 100% 50%; /* Blue */
42 | --chart-4: 60 100% 50%; /* Yellow */
43 | --chart-5: 300 100% 50%; /* Magenta */
44 |
45 | /* Sidebar specific theme variables (will be less used with new layout) */
46 | --sidebar-background: 0 0% 6%; /* Dark background for sidebar */
47 | --sidebar-foreground: 120 100% 50%; /* Neon Green text in sidebar */
48 | --sidebar-primary: 180 100% 50%; /* Cyan for active/primary items in sidebar */
49 | --sidebar-primary-foreground: 0 0% 0%; /* Black text on sidebar primary items */
50 | --sidebar-accent: 0 0% 10%; /* Darker background for hover/accent in sidebar */
51 | --sidebar-accent-foreground: 120 100% 60%; /* Brighter Neon Green for text on sidebar accent */
52 | --sidebar-border: 120 50% 15%; /* Dark green border for sidebar */
53 | --sidebar-ring: 180 100% 50%; /* Cyan for focus rings within sidebar */
54 | }
55 | }
56 |
57 | @layer base {
58 | * {
59 | @apply border-border;
60 | }
61 | body {
62 | @apply bg-background text-foreground font-mono; /* Apply monospace font globally */
63 | }
64 | }
65 |
66 | @keyframes blink-animation {
67 | to {
68 | visibility: hidden;
69 | }
70 | }
71 | .blinking-cursor {
72 | display: inline-block;
73 | animation: blink-animation 1s steps(2, start) infinite;
74 | font-weight: bold;
75 | }
76 |
77 | .text-center-green {
78 | color: #00ff00 !important;
79 | text-align: center;
80 | }
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function RootLayout({
4 | children,
5 | }: {
6 | children: React.ReactNode;
7 | }) {
8 | // Si estamos en la raíz, redirigir a /en
9 | if (typeof window !== 'undefined' && window.location.pathname === '/') {
10 | redirect('/en');
11 | }
12 |
13 | // Si no estamos en la raíz, mostrar el contenido
14 | return children;
15 | }
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 |
2 | // This file is no longer the root page.
3 | // Its content has been moved to src/app/[locale]/page.tsx
4 | // This file can be safely removed.
5 | export default function DeprecatedHomePage() {
6 | return Please navigate to a localized path like /es or /en.
;
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/AboutModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useScopedI18n } from '@/locales/client';
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogHeader,
7 | DialogTitle,
8 | DialogFooter,
9 | } from '@/components/ui/dialog';
10 | import { Button } from '@/components/ui/button';
11 | import { ScrollArea } from '@/components/ui/scroll-area';
12 |
13 | interface AboutModalProps {
14 | isOpen: boolean;
15 | onClose: () => void;
16 | }
17 |
18 | export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
19 | const t = useScopedI18n('about');
20 | // Construir features desde las claves i18n
21 | const features: string[] = [];
22 | let i = 0;
23 | while (true) {
24 | try {
25 | const key = `features.${i}` as any;
26 | const feature = t(key);
27 | if (feature && feature !== key) {
28 | features.push(feature);
29 | i++;
30 | } else {
31 | break;
32 | }
33 | } catch {
34 | break;
35 | }
36 | }
37 | return (
38 |
39 |
40 | {t('description')}
41 |
42 |
43 |
44 | 🏴☠️
45 |
46 |
47 | {t('title')} v1.0.0
48 |
49 |
50 |
51 | ●
52 | Status: OPERATIONAL
53 | ●
54 | Security: A+
55 | ●
56 | Ready for CTF
57 |
58 |
59 |
60 |
61 | {/* Misión */}
62 |
63 |
64 | 🎯
65 |
MISSION
66 |
67 |
{t('description')}
68 |
69 | {/* Features */}
70 |
71 |
72 | ⚡
73 |
{t('featuresTitle')}
74 |
75 |
76 | {features.map((feature: string, i: number) => (
77 |
78 | ▶
79 | {feature}
80 |
81 | ))}
82 |
83 |
84 | {/* Tech Stack */}
85 |
86 |
87 | 🛠️
88 |
TECH STACK
89 |
90 |
91 |
92 |
Frontend
93 |
Next.js 15
94 |
React 18
95 |
TypeScript
96 |
97 |
98 |
Styling
99 |
Tailwind CSS
100 |
Custom Terminal
101 |
Responsive
102 |
103 |
104 |
AI
105 |
Google Gemini
106 |
OpenAI GPT
107 |
Smart Prompts
108 |
109 |
110 |
Security
111 |
DOMPurify
112 |
Input Sanitization
113 |
XSS Protection
114 |
115 |
116 |
117 | {/* Developer Info */}
118 |
119 |
120 | 👨💻
121 |
DEVELOPER
122 |
123 |
124 |
125 |
126 | Name:
127 | Ilana Aminoff
128 |
129 |
130 | Handle:
131 | @ilanami
132 |
133 |
134 | Focus:
135 | Cybersecurity & CTF
136 |
137 |
138 |
171 |
172 |
173 | {/* Stats */}
174 |
175 |
176 | 📊
177 |
PROJECT STATS
178 |
179 |
180 |
181 |
100%
182 |
Security Score
183 |
184 |
185 |
2
186 |
AI Providers
187 |
188 |
189 |
∞
190 |
Write-ups
191 |
192 |
193 |
0
194 |
Data Tracking
195 |
196 |
197 |
198 | {/* Acknowledgments */}
199 |
200 |
201 | 🙏
202 |
{t('acknowledgements')}
203 |
204 |
205 | {t('thanks')}
206 |
207 |
208 | {/* Call to Action */}
209 |
210 |
Ready to dominate CTFs? 🏴☠️
211 |
212 | Create professional write-ups with AI assistance and secure your victories!
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 | Made with ❤️ for the CTF community
221 |
222 |
223 | window.open('https://github.com/ilanami/ctf_writeup_builder', '_blank')}
225 | variant="outline"
226 | className="border-green-500 text-green-500 hover:bg-green-500/10 font-mono text-xs"
227 | >
228 | ⭐ Star on GitHub
229 |
230 |
234 | {t('closeButton')}
235 |
236 |
237 |
238 |
239 |
240 |
241 | );
242 | }
--------------------------------------------------------------------------------
/src/components/ApiKeyConfigModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { useScopedI18n } from '@/locales/client';
3 |
4 | const PROVIDERS = [
5 | { value: 'gemini', label: 'Google Gemini' },
6 | { value: 'openai', label: 'OpenAI ChatGPT' },
7 | ];
8 |
9 | export default function ApiKeyConfigModal({ onSave, onCancel }: { onSave?: (data: { provider: string; apiKey: string }) => void; onCancel?: () => void }) {
10 | const [provider, setProvider] = useState('gemini');
11 | const [apiKey, setApiKey] = useState('');
12 | const [error, setError] = useState('');
13 | const tai = useScopedI18n('aiConfig');
14 |
15 | // Funciones de codificación Base64 (NO es encriptación)
16 | const encodeApiKey = (key: string): string => btoa(key);
17 | const decodeApiKey = (encoded: string): string => {
18 | try {
19 | return atob(encoded);
20 | } catch {
21 | return encoded; // Si no está codificado en Base64
22 | }
23 | };
24 |
25 | // Cargar valores guardados
26 | useEffect(() => {
27 | const savedProvider = localStorage.getItem('aiProvider');
28 | const savedEncodedKey = localStorage.getItem('aiApiKey');
29 |
30 | if (savedProvider) setProvider(savedProvider);
31 | if (savedEncodedKey) {
32 | const decodedKey = decodeApiKey(savedEncodedKey);
33 | setApiKey(decodedKey);
34 | }
35 | }, []);
36 |
37 | // Validar API Key
38 | const validateApiKey = (key: string, providerType: string): string | null => {
39 | if (!key.trim()) {
40 | return tai('errorApiKeyEmpty');
41 | }
42 | if (providerType === 'openai' && !key.startsWith('sk-')) {
43 | return tai('errorOpenAIPrefix');
44 | }
45 | if (key.length < 10) {
46 | return tai('errorApiKeyTooShort');
47 | }
48 | return null;
49 | };
50 |
51 | // Obtener enlace de ayuda
52 | const getHelpLink = () => {
53 | return provider === 'gemini'
54 | ? 'https://makersuite.google.com/app/apikey'
55 | : 'https://platform.openai.com/api-keys';
56 | };
57 |
58 | // Obtener título dinámico
59 | const getTitle = () => {
60 | return provider === 'gemini'
61 | ? tai('configureApiKeyTitleGemini')
62 | : tai('configureApiKeyTitleOpenAI');
63 | };
64 |
65 | const handleSave = () => {
66 | setError('');
67 | const validationError = validateApiKey(apiKey, provider);
68 | if (validationError) {
69 | setError(validationError);
70 | return;
71 | }
72 | // Codificar en Base64 y guardar (NO es encriptación)
73 | const encodedKey = encodeApiKey(apiKey);
74 | localStorage.setItem('aiProvider', provider);
75 | localStorage.setItem('aiApiKey', encodedKey);
76 | if (onSave) onSave({ provider, apiKey });
77 | };
78 |
79 | const handleDeleteApiKey = () => {
80 | const savedEncodedKey = localStorage.getItem('aiApiKey');
81 | if (!savedEncodedKey) {
82 | alert(tai('apiKeyEmptyTitle'));
83 | return;
84 | }
85 | localStorage.removeItem('aiApiKey');
86 | localStorage.removeItem('aiProvider');
87 | setApiKey('');
88 | setProvider('gemini');
89 | setError('');
90 | alert(tai('apiKeyDeletedTitle'));
91 | if (onSave) onSave({ provider: 'gemini', apiKey: '' });
92 | };
93 |
94 | return (
95 |
96 |
97 | {/* Título dinámico */}
98 |
99 | {getTitle()}
100 |
101 | {/* Selector de proveedor */}
102 |
103 | {tai('providerLabel')}
104 | setProvider(e.target.value)}
107 | style={styles.select}
108 | >
109 | {tai('providerGemini')}
110 | {tai('providerOpenAI')}
111 |
112 |
113 | {/* Instrucciones */}
114 |
115 | {provider === 'gemini' ? tai('configureApiKeyDescriptionGemini') : tai('configureApiKeyDescriptionOpenAI')}
116 |
117 |
118 | {tai('responsibilityNote')}
119 |
120 | {/* Input de API Key */}
121 |
122 |
123 | {provider === 'gemini' ? tai('apiKeyLabelGemini') : tai('apiKeyLabelOpenAI')}
124 |
125 |
{
129 | setApiKey(e.target.value);
130 | setError(''); // Limpiar error al escribir
131 | }}
132 | placeholder={tai('apiKeyPlaceholder')}
133 | style={styles.input}
134 | />
135 | {/* Enlace de ayuda */}
136 |
146 |
147 | {/* Error */}
148 | {error && (
149 |
150 | ⚠️ {error}
151 |
152 | )}
153 | {/* Aviso de seguridad */}
154 |
155 | {tai('securityNote')}
156 |
157 | {/* Botones */}
158 |
159 |
160 | {tai('cancelButton')}
161 |
162 |
163 | {tai('saveKeyButton')}
164 |
165 |
166 | {tai('deleteApiKeyButton')}
167 |
168 |
169 |
170 |
171 | );
172 | }
173 |
174 | // Estilos manteniendo tu tema hacker
175 | const styles = {
176 | overlay: {
177 | position: 'fixed' as 'fixed',
178 | top: 0,
179 | left: 0,
180 | right: 0,
181 | bottom: 0,
182 | backgroundColor: 'rgba(0, 0, 0, 0.8)',
183 | display: 'flex',
184 | alignItems: 'center',
185 | justifyContent: 'center',
186 | zIndex: 1000,
187 | },
188 | modal: {
189 | backgroundColor: '#000',
190 | color: '#00ff00',
191 | padding: '20px',
192 | borderRadius: '8px',
193 | border: '2px solid #00ff00',
194 | width: '90%',
195 | maxWidth: '500px',
196 | fontFamily: 'monospace',
197 | },
198 | title: {
199 | color: '#00ff00',
200 | fontSize: '18px',
201 | fontWeight: 'bold',
202 | marginBottom: '15px',
203 | textAlign: 'left' as 'left',
204 | },
205 | providerSection: {
206 | marginBottom: '15px',
207 | },
208 | providerLabel: {
209 | color: '#00ff00',
210 | fontSize: '14px',
211 | display: 'block',
212 | marginBottom: '5px',
213 | },
214 | select: {
215 | width: '100%',
216 | padding: '8px',
217 | backgroundColor: '#000',
218 | color: '#00ff00',
219 | border: '1px solid #00ff00',
220 | borderRadius: '4px',
221 | fontFamily: 'monospace',
222 | fontSize: '14px',
223 | },
224 | description: {
225 | color: '#00ff00',
226 | fontSize: '12px',
227 | lineHeight: '1.4',
228 | marginBottom: '10px',
229 | },
230 | responsibility: {
231 | color: '#00ff00',
232 | fontSize: '12px',
233 | marginBottom: '15px',
234 | fontWeight: 'bold',
235 | },
236 | inputSection: {
237 | marginBottom: '15px',
238 | },
239 | inputLabel: {
240 | color: '#00ff00',
241 | fontSize: '14px',
242 | display: 'block',
243 | marginBottom: '5px',
244 | },
245 | input: {
246 | width: '100%',
247 | padding: '10px',
248 | backgroundColor: '#000',
249 | color: '#00ff00',
250 | border: '1px solid #00ff00',
251 | borderRadius: '4px',
252 | fontFamily: 'monospace',
253 | fontSize: '14px',
254 | marginBottom: '8px',
255 | boxSizing: 'border-box' as 'border-box',
256 | },
257 | helpSection: {
258 | fontSize: '12px',
259 | color: '#00ff00',
260 | },
261 | helpLink: {
262 | color: '#00ff00',
263 | textDecoration: 'underline',
264 | },
265 | error: {
266 | backgroundColor: 'rgba(255, 0, 0, 0.2)',
267 | border: '1px solid #ff0000',
268 | color: '#ff0000',
269 | padding: '8px',
270 | borderRadius: '4px',
271 | marginBottom: '10px',
272 | fontSize: '12px',
273 | fontFamily: 'monospace',
274 | },
275 | securityNote: {
276 | fontSize: '11px',
277 | color: '#00ff00',
278 | opacity: 0.8,
279 | marginBottom: '15px',
280 | textAlign: 'center' as 'center',
281 | },
282 | buttonContainer: {
283 | display: 'flex',
284 | justifyContent: 'flex-end',
285 | gap: '10px',
286 | },
287 | cancelButton: {
288 | padding: '8px 15px',
289 | backgroundColor: 'transparent',
290 | color: '#00ff00',
291 | border: '1px solid #00ff00',
292 | borderRadius: '4px',
293 | cursor: 'pointer',
294 | fontFamily: 'monospace',
295 | fontSize: '14px',
296 | },
297 | saveButton: {
298 | padding: '8px 15px',
299 | backgroundColor: '#00ff00',
300 | color: '#000',
301 | border: '1px solid #00ff00',
302 | borderRadius: '4px',
303 | cursor: 'pointer',
304 | fontFamily: 'monospace',
305 | fontSize: '14px',
306 | fontWeight: 'bold',
307 | },
308 | };
--------------------------------------------------------------------------------
/src/components/GeneralInfoPanel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from 'react';
4 | import { useWriteUp } from '@/hooks/useWriteUp';
5 | import { Button } from '@/components/ui/button';
6 | import { Input } from '@/components/ui/input';
7 | import { Label } from '@/components/ui/label';
8 | import {
9 | Select,
10 | SelectContent,
11 | SelectItem,
12 | SelectTrigger,
13 | SelectValue,
14 | } from '@/components/ui/select';
15 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
16 | import { Calendar } from "@/components/ui/calendar";
17 | import { CalendarIcon } from 'lucide-react';
18 | import { format, parseISO } from 'date-fns';
19 | import { es, enUS } from 'date-fns/locale';
20 | import { DIFFICULTIES_KEYS, OS_KEYS } from '@/lib/constants';
21 | import type { Difficulty, OperatingSystem, Screenshot } from '@/lib/types';
22 | import { ImageUploader } from './ImageUploader';
23 | import { TagInput } from './TagInput';
24 | import { ScrollArea } from '@/components/ui/scroll-area';
25 | import { useScopedI18n, useCurrentLocale } from '@/locales/client';
26 |
27 | export const GeneralInfoPanel: React.FC = () => {
28 | const { state, dispatch } = useWriteUp();
29 | const { writeUp } = state;
30 | const t = useScopedI18n('generalInfo');
31 | const currentLocale = useCurrentLocale();
32 | const dateLocale = currentLocale === 'es' ? es : enUS;
33 | const tDifficulties = useScopedI18n('difficulties');
34 | const tOS = useScopedI18n('operatingSystems');
35 |
36 |
37 | const handleInputChange = (field: keyof typeof writeUp, value: any) => {
38 | dispatch({ type: 'UPDATE_GENERAL_INFO', payload: { [field]: value } });
39 | };
40 |
41 | const handleDateChange = (date: Date | undefined) => {
42 | if (date) {
43 | handleInputChange('date', date.toISOString().split('T')[0]);
44 | }
45 | };
46 |
47 | const handleMachineImageUpload = (screenshot: Screenshot) => {
48 | dispatch({ type: 'SET_MACHINE_IMAGE', payload: screenshot });
49 | };
50 |
51 | const handleMachineImageRemove = () => {
52 | dispatch({ type: 'SET_MACHINE_IMAGE', payload: undefined });
53 | };
54 |
55 | return (
56 |
57 |
58 |
{t('title')}
59 |
60 |
61 | {t('writeupTitle')}
62 | handleInputChange('title', e.target.value)}
66 | placeholder={t('writeupTitlePlaceholder')}
67 | />
68 |
69 |
70 |
71 | {t('author')}
72 | handleInputChange('author', e.target.value)}
76 | placeholder={t('authorPlaceholder')}
77 | />
78 |
79 |
80 |
81 |
{t('date')}
82 |
83 |
84 |
89 |
90 | {writeUp.date ? (() => {
91 | try {
92 | return format(parseISO(writeUp.date), "PPP", { locale: dateLocale });
93 | } catch {
94 | return writeUp.date;
95 | }
96 | })() : {t('selectDate')} }
97 |
98 |
99 |
100 |
107 |
108 |
109 |
110 |
111 |
112 | {t('difficulty')}
113 | handleInputChange('difficulty', value)}
116 | >
117 |
118 |
119 |
120 |
121 | {DIFFICULTIES_KEYS.map(dKey => (
122 |
123 | {tDifficulties(dKey as any)}
124 |
125 | ))}
126 |
127 |
128 |
129 |
130 | {t('tags')}
131 | handleInputChange('tags', tags)}
135 | placeholder={t('addTag')}
136 | />
137 |
138 |
139 |
140 | {t('os')}
141 | handleInputChange('os', value)}
144 | >
145 |
146 |
147 |
148 |
149 | {OS_KEYS.map(osKey => (
150 |
151 | {tOS(osKey as any)}
152 |
153 | ))}
154 |
155 |
156 |
157 |
158 |
159 | {t('machineImage')}
160 |
167 |
168 |
169 |
170 | );
171 | };
172 |
--------------------------------------------------------------------------------
/src/components/ImageUploader.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { ChangeEvent } from 'react';
4 | import React, { useState, useRef } from 'react';
5 | import type { Screenshot } from '@/lib/types';
6 | import { Button } from '@/components/ui/button';
7 | import { Input } from '@/components/ui/input';
8 | import { Card, CardContent, CardFooter } from '@/components/ui/card';
9 | import Image from 'next/image';
10 | import { UploadCloud, XCircle } from 'lucide-react';
11 | import { v4 as uuidv4 } from 'uuid';
12 | import { cn } from '@/lib/utils';
13 | import { useI18n } from '@/locales/client'; // Import I18n hook
14 |
15 | interface ImageUploaderProps {
16 | onImageUpload: (screenshot: Screenshot) => void;
17 | onImageRemove?: () => void; // For single image uploader like machine image
18 | currentImage?: Screenshot;
19 | label?: string;
20 | className?: string;
21 | accept?: string;
22 | id?: string;
23 | canUpload?: () => boolean;
24 | }
25 |
26 | const MAX_FILE_SIZE_MB = 5;
27 | const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
28 |
29 | export const ImageUploader: React.FC = ({
30 | onImageUpload,
31 | onImageRemove,
32 | currentImage,
33 | label, // Label is passed as a prop, already translated by parent
34 | className,
35 | accept = "image/*",
36 | id = "image-upload",
37 | canUpload
38 | }) => {
39 | const t = useI18n();
40 | const [error, setError] = useState(null);
41 | const [isDragging, setIsDragging] = useState(false);
42 | const fileInputRef = useRef(null);
43 |
44 | const handleFileChange = (event: ChangeEvent) => {
45 | const file = event.target.files?.[0];
46 | if (file) {
47 | processFile(file);
48 | }
49 | };
50 |
51 | const processFile = (file: File) => {
52 | if (file.size > MAX_FILE_SIZE_BYTES) {
53 | setError(t('imageUploader.fileTooLarge', { maxSize: MAX_FILE_SIZE_MB }));
54 | return;
55 | }
56 | if (!file.type.startsWith('image/')) {
57 | setError(t('imageUploader.invalidFileType'));
58 | return;
59 | }
60 | setError(null);
61 | const reader = new FileReader();
62 | reader.onloadend = () => {
63 | onImageUpload({
64 | id: uuidv4(),
65 | name: file.name,
66 | dataUrl: reader.result as string,
67 | });
68 | };
69 | reader.onerror = () => {
70 | setError(t('imageUploader.errorReadingFile'));
71 | };
72 | reader.readAsDataURL(file);
73 | };
74 |
75 | const handleDragOver = (event: React.DragEvent) => {
76 | event.preventDefault();
77 | event.stopPropagation();
78 | setIsDragging(true);
79 | };
80 |
81 | const handleDragLeave = (event: React.DragEvent) => {
82 | event.preventDefault();
83 | event.stopPropagation();
84 | setIsDragging(false);
85 | };
86 |
87 | const handleDrop = (event: React.DragEvent) => {
88 | event.preventDefault();
89 | event.stopPropagation();
90 | setIsDragging(false);
91 | if (canUpload && !canUpload()) return;
92 | const file = event.dataTransfer.files?.[0];
93 | if (file) {
94 | processFile(file);
95 | }
96 | };
97 |
98 | const triggerFileInput = () => {
99 | if (canUpload && !canUpload()) return;
100 | fileInputRef.current?.click();
101 | };
102 |
103 | return (
104 |
105 |
106 | {currentImage ? (
107 |
108 |
115 | {onImageRemove && (
116 |
123 |
124 |
125 | )}
126 |
127 | ) : (
128 | e.key === 'Enter' && triggerFileInput()}
134 | >
135 |
136 |
{label || t('imageUploader.defaultLabel')}
137 |
{t('imageUploader.dragDropOrClick')}
138 |
{t('imageUploader.maxFileSize', { maxSize: MAX_FILE_SIZE_MB })}
139 |
140 | )}
141 |
149 | {error && {error}
}
150 |
151 | {!currentImage && (
152 |
153 |
154 | {t('imageUploader.selectFileButton')}
155 |
156 |
157 | )}
158 |
159 | );
160 | };
161 |
--------------------------------------------------------------------------------
/src/components/MarkdownEditor.tsx:
--------------------------------------------------------------------------------
1 |
2 | "use client";
3 |
4 | import React, { useRef } from 'react';
5 | import { Textarea } from '@/components/ui/textarea';
6 | import { Label } from '@/components/ui/label';
7 | import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
8 | import { Button } from '@/components/ui/button';
9 | import { Bold, Italic, List, ListOrdered, Code2, Link2 } from 'lucide-react';
10 | import { cn } from '@/lib/utils';
11 |
12 | interface MarkdownEditorProps {
13 | value: string;
14 | onChange: (value: string) => void;
15 | label?: string;
16 | placeholder?: string;
17 | className?: string;
18 | rows?: number;
19 | id?: string;
20 | error?: string;
21 | showWordCount?: boolean;
22 | }
23 |
24 | export const MarkdownEditor: React.FC = ({
25 | value,
26 | onChange,
27 | label,
28 | placeholder = "Escribe tu contenido Markdown aquí...",
29 | className,
30 | rows = 10,
31 | id = "markdown-editor",
32 | error,
33 | showWordCount = true,
34 | }) => {
35 | const textareaRef = useRef(null);
36 | const wordCount = value?.split(/\s+/).filter(Boolean).length || 0;
37 | const charCount = value?.length || 0;
38 |
39 | const handleFormat = (type: 'bold' | 'italic' | 'ul' | 'ol' | 'codeblock' | 'link') => {
40 | const textarea = textareaRef.current;
41 | if (!textarea) return;
42 |
43 | const currentValue = textarea.value;
44 | const start = textarea.selectionStart;
45 | const end = textarea.selectionEnd;
46 | const selectedText = currentValue.substring(start, end);
47 |
48 | let newText = '';
49 | let newSelectionStart = start;
50 | let newSelectionEnd = end;
51 |
52 | const prefix = currentValue.substring(0, start);
53 | const suffix = currentValue.substring(end);
54 |
55 | switch (type) {
56 | case 'bold':
57 | if (selectedText) {
58 | newText = `${prefix}**${selectedText}**${suffix}`;
59 | newSelectionStart = start + 2;
60 | newSelectionEnd = start + 2 + selectedText.length;
61 | } else {
62 | newText = `${prefix}****${suffix}`;
63 | newSelectionStart = start + 2;
64 | newSelectionEnd = start + 2;
65 | }
66 | break;
67 | case 'italic':
68 | if (selectedText) {
69 | newText = `${prefix}*${selectedText}*${suffix}`;
70 | newSelectionStart = start + 1;
71 | newSelectionEnd = start + 1 + selectedText.length;
72 | } else {
73 | newText = `${prefix}**${suffix}`; // For italic placeholder, should be * *
74 | newSelectionStart = start + 1;
75 | newSelectionEnd = start + 1;
76 | }
77 | break;
78 | case 'ul':
79 | if (selectedText) {
80 | const lines = selectedText.split('\n');
81 | const formattedLines = lines.map(line => `- ${line}`).join('\n');
82 | newText = `${prefix}${formattedLines}${suffix}`;
83 | newSelectionStart = start;
84 | newSelectionEnd = start + formattedLines.length;
85 | } else {
86 | const linePrefix = (start === 0 || currentValue[start - 1] === '\n') ? '' : '\n';
87 | newText = `${prefix}${linePrefix}- ${suffix}`;
88 | newSelectionStart = start + linePrefix.length + 2;
89 | newSelectionEnd = newSelectionStart;
90 | }
91 | break;
92 | case 'ol':
93 | if (selectedText) {
94 | const lines = selectedText.split('\n');
95 | const formattedLines = lines.map((line, index) => `${index + 1}. ${line}`).join('\n');
96 | newText = `${prefix}${formattedLines}${suffix}`;
97 | newSelectionStart = start;
98 | newSelectionEnd = start + formattedLines.length;
99 | } else {
100 | const linePrefix = (start === 0 || currentValue[start - 1] === '\n') ? '' : '\n';
101 | newText = `${prefix}${linePrefix}1. ${suffix}`;
102 | newSelectionStart = start + linePrefix.length + 3;
103 | newSelectionEnd = newSelectionStart;
104 | }
105 | break;
106 | case 'codeblock':
107 | if (selectedText) {
108 | newText = `${prefix}\`\`\`\n${selectedText}\n\`\`\`${suffix}`;
109 | newSelectionStart = start + prefix.length + 4;
110 | newSelectionEnd = newSelectionStart + selectedText.length;
111 | } else {
112 | newText = `${prefix}\`\`\`\n\n\`\`\`${suffix}`;
113 | newSelectionStart = start + prefix.length + 4;
114 | newSelectionEnd = newSelectionStart;
115 | }
116 | break;
117 | case 'link':
118 | const linkDisplayText = selectedText || 'texto_del_enlace';
119 | newText = `${prefix}[${linkDisplayText}](aqui_la_url)${suffix}`;
120 | if (selectedText) {
121 | newSelectionStart = prefix.length + 1 + linkDisplayText.length + 2;
122 | newSelectionEnd = newSelectionStart + 'aqui_la_url'.length;
123 | } else {
124 | newSelectionStart = prefix.length + 1;
125 | newSelectionEnd = newSelectionStart + linkDisplayText.length;
126 | }
127 | break;
128 | }
129 |
130 | onChange(newText);
131 |
132 | setTimeout(() => {
133 | if (textareaRef.current) {
134 | textareaRef.current.focus();
135 | textareaRef.current.setSelectionRange(newSelectionStart, newSelectionEnd);
136 | }
137 | }, 0);
138 | };
139 |
140 |
141 | return (
142 |
143 | {label && (
144 |
145 | {label}
146 |
147 | )}
148 |
149 |
150 | handleFormat('bold')} title="Negrita (Ctrl+B)" className="h-7 w-7">
151 |
152 |
153 | handleFormat('italic')} title="Cursiva (Ctrl+I)" className="h-7 w-7">
154 |
155 |
156 | handleFormat('ul')} title="Lista Desordenada" className="h-7 w-7">
157 |
158 |
159 | handleFormat('ol')} title="Lista Ordenada" className="h-7 w-7">
160 |
161 |
162 | handleFormat('codeblock')} title="Bloque de Código" className="h-7 w-7">
163 |
164 |
165 | handleFormat('link')} title="Enlace" className="h-7 w-7">
166 |
167 |
168 |
169 |
184 | {(error || showWordCount) && (
185 |
186 | {error && {error}
}
187 | {showWordCount && !error && (
188 |
189 | Palabras: {wordCount} | Caracteres: {charCount}
190 |
191 | )}
192 |
193 | )}
194 |
195 | );
196 | };
197 |
198 |
--------------------------------------------------------------------------------
/src/components/SectionItemCard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from 'react';
4 | import type { WriteUpSection } from '@/lib/types';
5 | import { Button } from '@/components/ui/button';
6 | import { Card, CardContent } from '@/components/ui/card';
7 | import { Trash2 } from 'lucide-react';
8 | import { cn } from '@/lib/utils';
9 | import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
10 | import { useI18n, useScopedI18n } from '@/locales/client';
11 |
12 | interface SectionItemCardProps {
13 | section: WriteUpSection;
14 | icon: React.ReactNode;
15 | onSelect: () => void;
16 | onDelete: () => void;
17 | isActive: boolean;
18 | className?: string;
19 | }
20 |
21 | function isValidI18nKey(key: string, t: any): boolean {
22 | try {
23 | return t(key, {}) !== key;
24 | } catch {
25 | return false;
26 | }
27 | }
28 |
29 | export const SectionItemCard: React.FC = ({
30 | section,
31 | icon,
32 | onSelect,
33 | onDelete,
34 | isActive,
35 | className,
36 | }) => {
37 | const t = useI18n();
38 | const tsp = useScopedI18n('structurePanel');
39 | const th = useScopedI18n('header');
40 | const isI18nKey =
41 | typeof section.title === 'string' &&
42 | (section.title.startsWith('defaultSections.') || section.title.startsWith('sectionTypes.')) &&
43 | isValidI18nKey(section.title, t);
44 |
45 | const displayTitle = isI18nKey
46 | ? t(section.title as any, {})
47 | : section.title;
48 |
49 |
50 | return (
51 |
60 |
61 |
62 | {icon && React.isValidElement(icon) && className?.includes('compact')
63 | ? React.cloneElement(icon, { ...icon.props, className: cn(icon.props?.className, "h-3 w-3 mr-1") }, icon.props.children ?? null)
64 | : icon}
65 |
70 | {displayTitle || tsp('untitledSection')}
71 |
72 |
73 |
74 |
75 | { e.stopPropagation(); }}
83 | aria-label={tsp('deleteSectionAriaLabel', { title: displayTitle })}
84 | >
85 |
86 |
87 |
88 |
89 |
90 | {tsp('deleteSectionTitle')}
91 |
92 | {tsp('deleteSectionDescription', { title: displayTitle })}
93 |
94 |
95 |
96 | e.stopPropagation()}>{th('cancel')}
97 | { e.stopPropagation(); onDelete();}} className="bg-destructive hover:bg-destructive/90 text-destructive-foreground">{th('continue')}
98 |
99 |
100 |
101 |
102 |
103 | );
104 | };
105 |
--------------------------------------------------------------------------------
/src/components/SectionsManager.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | // This file is no longer used directly in the new layout.
3 | // Its functionality for listing sections and adding sections has been
4 | // moved into new components within AppLayout.tsx (StructureAndAddSectionsPanel)
5 | // and SectionItemCard.tsx handles individual item display.
6 | // Keeping the file for now in case any helper functions or types are needed,
7 | // but it should be considered for removal or refactoring if completely unused.
8 |
9 | import React from 'react';
10 |
11 | const SectionsManagerMessage: React.FC = () => {
12 | return (
13 |
14 | SectionsManager.tsx is being refactored. Its content is now part of the main AppLayout.
15 |
16 | );
17 | };
18 |
19 | export default SectionsManagerMessage;
20 |
--------------------------------------------------------------------------------
/src/components/TagInput.tsx:
--------------------------------------------------------------------------------
1 |
2 | "use client";
3 |
4 | import React, { useState, KeyboardEvent } from 'react';
5 | import { Input } from '@/components/ui/input';
6 | import { Button } from '@/components/ui/button';
7 | import { Badge } from '@/components/ui/badge';
8 | import { X } from 'lucide-react';
9 | import { cn } from '@/lib/utils';
10 | import { useI18n } from '@/locales/client'; // Import useI18n
11 |
12 | interface TagInputProps {
13 | tags: string[];
14 | onTagsChange: (tags: string[]) => void;
15 | placeholder?: string;
16 | className?: string;
17 | id?: string;
18 | }
19 |
20 | export const TagInput: React.FC = ({
21 | tags,
22 | onTagsChange,
23 | placeholder = "Añadir tag...",
24 | className,
25 | id = "tag-input"
26 | }) => {
27 | const [inputValue, setInputValue] = useState('');
28 | const t = useI18n(); // Initialize useI18n
29 |
30 | const handleInputChange = (event: React.ChangeEvent) => {
31 | setInputValue(event.target.value);
32 | };
33 |
34 | const handleInputKeyDown = (event: KeyboardEvent) => {
35 | if (event.key === 'Enter' || event.key === ',') {
36 | event.preventDefault();
37 | addTag();
38 | } else if (event.key === 'Backspace' && inputValue === '' && tags.length > 0) {
39 | removeTag(tags.length - 1);
40 | }
41 | };
42 |
43 | const addTag = () => {
44 | const newTag = inputValue.trim();
45 | if (newTag && !tags.includes(newTag)) {
46 | onTagsChange([...tags, newTag]);
47 | }
48 | setInputValue('');
49 | };
50 |
51 | const removeTag = (indexToRemove: number) => {
52 | onTagsChange(tags.filter((_, index) => index !== indexToRemove));
53 | };
54 |
55 | return (
56 |
57 |
58 |
67 |
68 | {t('tagInput.addButton')} {/* Use translated text */}
69 |
70 |
71 | {tags.length > 0 && (
72 |
73 | {tags.map((tag, index) => (
74 |
75 | {tag}
76 | removeTag(index)}
79 | className="rounded-full opacity-50 group-hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
80 | aria-label={`${t('tagInput.removeTagButtonLabel')} ${tag}`} // Translate aria-label
81 | >
82 |
83 |
84 |
85 | ))}
86 |
87 | )}
88 |
89 | );
90 | };
91 |
92 |
--------------------------------------------------------------------------------
/src/components/WysiwygEditor.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { EditorContent, useEditor } from '@tiptap/react';
3 | import StarterKit from '@tiptap/starter-kit';
4 | import Link from '@tiptap/extension-link';
5 | import Underline from '@tiptap/extension-underline';
6 | import TextAlign from '@tiptap/extension-text-align';
7 | import Placeholder from '@tiptap/extension-placeholder';
8 | import { Button } from '@/components/ui/button';
9 | import { Bold, Italic, Link2, Underline as UnderlineIcon, Code2, Code, List } from 'lucide-react';
10 | import { cn } from '@/lib/utils';
11 |
12 | interface WysiwygEditorProps {
13 | value: string;
14 | onChange: (value: string) => void;
15 | label?: string;
16 | placeholder?: string;
17 | className?: string;
18 | id?: string;
19 | }
20 |
21 | export const WysiwygEditor: React.FC = ({
22 | value,
23 | onChange,
24 | label,
25 | placeholder = 'Escribe tu contenido aquí...',
26 | className,
27 | id = 'wysiwyg-editor',
28 | }) => {
29 | const processTemplateContent = (content: string) => {
30 | if (!content) return '';
31 | return content
32 | .replace(/\n\n/g, '') // Double line breaks = new paragraphs
33 | .replace(/\n/g, ' ') // Single line breaks = tags
34 | .replace(/^/, '
') // Start with paragraph
35 | .replace(/$/, '
'); // End with paragraph
36 | };
37 |
38 | const editor = useEditor({
39 | extensions: [
40 | StarterKit,
41 | Link.configure({ openOnClick: false }),
42 | Underline,
43 | TextAlign.configure({ types: ['heading', 'paragraph'] }),
44 | Placeholder.configure({ placeholder }),
45 | ],
46 | content: processTemplateContent(value),
47 | onUpdate: ({ editor }) => {
48 | onChange(editor.getHTML());
49 | },
50 | editorProps: {
51 | attributes: {
52 | class: 'min-h-[200px] p-2 focus:outline-none prose prose-invert bg-background text-foreground rounded-b-md',
53 | id,
54 | },
55 | },
56 | immediatelyRender: false,
57 | });
58 |
59 | useEffect(() => {
60 | if (editor && value !== editor.getHTML()) {
61 | editor.commands.setContent(processTemplateContent(value) || '', false);
62 | }
63 | // eslint-disable-next-line react-hooks/exhaustive-deps
64 | }, [value]);
65 |
66 | if (!editor) return null;
67 |
68 | return (
69 |
70 | {label &&
{label} }
71 |
72 | editor.chain().focus().toggleBold().run()} className={editor.isActive('bold') ? 'bg-accent' : ''} title="Negrita (Ctrl+B)">
73 | editor.chain().focus().toggleItalic().run()} className={editor.isActive('italic') ? 'bg-accent' : ''} title="Cursiva (Ctrl+I)">
74 | editor.chain().focus().toggleUnderline().run()} className={editor.isActive('underline') ? 'bg-accent' : ''} title="Subrayado (Ctrl+U)">
75 | editor.chain().focus().toggleCodeBlock().run()} className={editor.isActive('codeBlock') ? 'bg-accent' : ''} title="Bloque de código (Ctrl+Alt+C)">
76 | editor.chain().focus().toggleCode().run()} className={editor.isActive('code') ? 'bg-accent' : ''} title="Código en línea (Ctrl+E)">
77 | editor.chain().focus().toggleBulletList().run()} className={editor.isActive('bulletList') ? 'bg-accent' : ''} title="Lista de viñetas">
78 | {
79 | const url = window.prompt('URL del enlace');
80 | if (url) editor.chain().focus().setLink({ href: url }).run();
81 | }} className={editor.isActive('link') ? 'bg-accent' : ''} title="Enlace">
82 |
83 |
84 |
85 | );
86 | };
87 |
88 | export default WysiwygEditor;
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | AccordionItem.displayName = "AccordionItem"
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180",
32 | className
33 | )}
34 | {...props}
35 | >
36 | {children}
37 |
38 |
39 |
40 | ))
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
52 | {children}
53 |
54 | ))
55 |
56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
57 |
58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
59 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
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 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
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 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
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 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
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 badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-foreground text-primary-foreground hover:bg-foreground/90 font-bold", // Changed bg-primary to bg-foreground, added font-bold
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", // Accent hover will be green if globals.css accent is changed, or remains cyan if not. User wants 'azul claro' to green.
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-foreground underline-offset-4 hover:underline font-bold", // Changed text-primary to text-foreground, added font-bold
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ChevronLeft, ChevronRight } from "lucide-react"
5 | import { DayPicker } from "react-day-picker"
6 |
7 | import { cn } from "@/lib/utils"
8 | import { buttonVariants } from "@/components/ui/button"
9 |
10 | export type CalendarProps = React.ComponentProps
11 |
12 | function Calendar({
13 | className,
14 | classNames,
15 | showOutsideDays = true,
16 | ...props
17 | }: CalendarProps) {
18 | return (
19 | (
58 |
59 | ),
60 | IconRight: ({ className, ...props }) => (
61 |
62 | ),
63 | }}
64 | {...props}
65 | />
66 | )
67 | }
68 | Calendar.displayName = "Calendar"
69 |
70 | export { Calendar }
71 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLDivElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { Check } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { Slot } from "@radix-ui/react-slot"
6 | import {
7 | Controller,
8 | FormProvider,
9 | useFormContext,
10 | type ControllerProps,
11 | type FieldPath,
12 | type FieldValues,
13 | } from "react-hook-form"
14 |
15 | import { cn } from "@/lib/utils"
16 | import { Label } from "@/components/ui/label"
17 |
18 | const Form = FormProvider
19 |
20 | type FormFieldContextValue<
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath
23 | > = {
24 | name: TName
25 | }
26 |
27 | const FormFieldContext = React.createContext(
28 | {} as FormFieldContextValue
29 | )
30 |
31 | const FormField = <
32 | TFieldValues extends FieldValues = FieldValues,
33 | TName extends FieldPath = FieldPath
34 | >({
35 | ...props
36 | }: ControllerProps) => {
37 | return (
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | const useFormField = () => {
45 | const fieldContext = React.useContext(FormFieldContext)
46 | const itemContext = React.useContext(FormItemContext)
47 | const { getFieldState, formState } = useFormContext()
48 |
49 | const fieldState = getFieldState(fieldContext.name, formState)
50 |
51 | if (!fieldContext) {
52 | throw new Error("useFormField should be used within ")
53 | }
54 |
55 | const { id } = itemContext
56 |
57 | return {
58 | id,
59 | name: fieldContext.name,
60 | formItemId: `${id}-form-item`,
61 | formDescriptionId: `${id}-form-item-description`,
62 | formMessageId: `${id}-form-item-message`,
63 | ...fieldState,
64 | }
65 | }
66 |
67 | type FormItemContextValue = {
68 | id: string
69 | }
70 |
71 | const FormItemContext = React.createContext(
72 | {} as FormItemContextValue
73 | )
74 |
75 | const FormItem = React.forwardRef<
76 | HTMLDivElement,
77 | React.HTMLAttributes
78 | >(({ className, ...props }, ref) => {
79 | const id = React.useId()
80 |
81 | return (
82 |
83 |
84 |
85 | )
86 | })
87 | FormItem.displayName = "FormItem"
88 |
89 | const FormLabel = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => {
93 | const { error, formItemId } = useFormField()
94 |
95 | return (
96 |
102 | )
103 | })
104 | FormLabel.displayName = "FormLabel"
105 |
106 | const FormControl = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ ...props }, ref) => {
110 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
111 |
112 | return (
113 |
124 | )
125 | })
126 | FormControl.displayName = "FormControl"
127 |
128 | const FormDescription = React.forwardRef<
129 | HTMLParagraphElement,
130 | React.HTMLAttributes
131 | >(({ className, ...props }, ref) => {
132 | const { formDescriptionId } = useFormField()
133 |
134 | return (
135 |
141 | )
142 | })
143 | FormDescription.displayName = "FormDescription"
144 |
145 | const FormMessage = React.forwardRef<
146 | HTMLParagraphElement,
147 | React.HTMLAttributes
148 | >(({ className, children, ...props }, ref) => {
149 | const { error, formMessageId } = useFormField()
150 | const body = error ? String(error?.message ?? "") : children
151 |
152 | if (!body) {
153 | return null
154 | }
155 |
156 | return (
157 |
163 | {body}
164 |
165 | )
166 | })
167 | FormMessage.displayName = "FormMessage"
168 |
169 | export {
170 | useFormField,
171 | Form,
172 | FormItem,
173 | FormLabel,
174 | FormControl,
175 | FormDescription,
176 | FormMessage,
177 | FormField,
178 | }
179 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/menubar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as MenubarPrimitive from "@radix-ui/react-menubar"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function MenubarMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function MenubarGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function MenubarPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function MenubarRadioGroup({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function MenubarSub({
34 | ...props
35 | }: React.ComponentProps) {
36 | return
37 | }
38 |
39 | const Menubar = React.forwardRef<
40 | React.ElementRef,
41 | React.ComponentPropsWithoutRef
42 | >(({ className, ...props }, ref) => (
43 |
51 | ))
52 | Menubar.displayName = MenubarPrimitive.Root.displayName
53 |
54 | const MenubarTrigger = React.forwardRef<
55 | React.ElementRef,
56 | React.ComponentPropsWithoutRef
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
68 |
69 | const MenubarSubTrigger = React.forwardRef<
70 | React.ElementRef,
71 | React.ComponentPropsWithoutRef & {
72 | inset?: boolean
73 | }
74 | >(({ className, inset, children, ...props }, ref) => (
75 |
84 | {children}
85 |
86 |
87 | ))
88 | MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
89 |
90 | const MenubarSubContent = React.forwardRef<
91 | React.ElementRef,
92 | React.ComponentPropsWithoutRef
93 | >(({ className, ...props }, ref) => (
94 |
102 | ))
103 | MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
104 |
105 | const MenubarContent = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(
109 | (
110 | { className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
111 | ref
112 | ) => (
113 |
114 |
125 |
126 | )
127 | )
128 | MenubarContent.displayName = MenubarPrimitive.Content.displayName
129 |
130 | const MenubarItem = React.forwardRef<
131 | React.ElementRef,
132 | React.ComponentPropsWithoutRef & {
133 | inset?: boolean
134 | }
135 | >(({ className, inset, ...props }, ref) => (
136 |
145 | ))
146 | MenubarItem.displayName = MenubarPrimitive.Item.displayName
147 |
148 | const MenubarCheckboxItem = React.forwardRef<
149 | React.ElementRef,
150 | React.ComponentPropsWithoutRef
151 | >(({ className, children, checked, ...props }, ref) => (
152 |
161 |
162 |
163 |
164 |
165 |
166 | {children}
167 |
168 | ))
169 | MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
170 |
171 | const MenubarRadioItem = React.forwardRef<
172 | React.ElementRef,
173 | React.ComponentPropsWithoutRef
174 | >(({ className, children, ...props }, ref) => (
175 |
183 |
184 |
185 |
186 |
187 |
188 | {children}
189 |
190 | ))
191 | MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
192 |
193 | const MenubarLabel = React.forwardRef<
194 | React.ElementRef,
195 | React.ComponentPropsWithoutRef & {
196 | inset?: boolean
197 | }
198 | >(({ className, inset, ...props }, ref) => (
199 |
208 | ))
209 | MenubarLabel.displayName = MenubarPrimitive.Label.displayName
210 |
211 | const MenubarSeparator = React.forwardRef<
212 | React.ElementRef,
213 | React.ComponentPropsWithoutRef
214 | >(({ className, ...props }, ref) => (
215 |
220 | ))
221 | MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
222 |
223 | const MenubarShortcut = ({
224 | className,
225 | ...props
226 | }: React.HTMLAttributes) => {
227 | return (
228 |
235 | )
236 | }
237 | MenubarShortcut.displayname = "MenubarShortcut"
238 |
239 | export {
240 | Menubar,
241 | MenubarMenu,
242 | MenubarTrigger,
243 | MenubarContent,
244 | MenubarItem,
245 | MenubarSeparator,
246 | MenubarLabel,
247 | MenubarCheckboxItem,
248 | MenubarRadioGroup,
249 | MenubarRadioItem,
250 | MenubarPortal,
251 | MenubarSubContent,
252 | MenubarSubTrigger,
253 | MenubarGroup,
254 | MenubarSub,
255 | MenubarShortcut,
256 | }
257 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ProgressPrimitive from "@radix-ui/react-progress"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Progress = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, value, ...props }, ref) => (
12 |
20 |
24 |
25 | ))
26 | Progress.displayName = ProgressPrimitive.Root.displayName
27 |
28 | export { Progress }
29 |
--------------------------------------------------------------------------------
/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5 | import { Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | )
20 | })
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | )
41 | })
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
43 |
44 | export { RadioGroup, RadioGroupItem }
45 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SliderPrimitive from "@radix-ui/react-slider"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Slider = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
21 |
22 |
23 |
24 |
25 | ))
26 | Slider.displayName = SliderPrimitive.Root.displayName
27 |
28 | export { Slider }
29 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 |
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 |
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import {cn} from '@/lib/utils';
4 |
5 | const Textarea = React.forwardRef>(
6 | ({className, ...props}, ref) => {
7 | return (
8 |
16 | );
17 | }
18 | );
19 | Textarea.displayName = 'Textarea';
20 |
21 | export {Textarea};
22 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ToastPrimitives from "@radix-ui/react-toast"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-background text-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | }
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useToast } from "@/hooks/use-toast"
4 | import {
5 | Toast,
6 | ToastClose,
7 | ToastDescription,
8 | ToastProvider,
9 | ToastTitle,
10 | ToastViewport,
11 | } from "@/components/ui/toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title} }
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/src/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react"
5 |
6 | import type {
7 | ToastActionElement,
8 | ToastProps,
9 | } from "@/components/ui/toast"
10 |
11 | const TOAST_LIMIT = 1
12 | const TOAST_REMOVE_DELAY = 1000000
13 |
14 | type ToasterToast = ToastProps & {
15 | id: string
16 | title?: React.ReactNode
17 | description?: React.ReactNode
18 | action?: ToastActionElement
19 | }
20 |
21 | const actionTypes = {
22 | ADD_TOAST: "ADD_TOAST",
23 | UPDATE_TOAST: "UPDATE_TOAST",
24 | DISMISS_TOAST: "DISMISS_TOAST",
25 | REMOVE_TOAST: "REMOVE_TOAST",
26 | } as const
27 |
28 | let count = 0
29 |
30 | function genId() {
31 | count = (count + 1) % Number.MAX_SAFE_INTEGER
32 | return count.toString()
33 | }
34 |
35 | type ActionType = typeof actionTypes
36 |
37 | type Action =
38 | | {
39 | type: ActionType["ADD_TOAST"]
40 | toast: ToasterToast
41 | }
42 | | {
43 | type: ActionType["UPDATE_TOAST"]
44 | toast: Partial
45 | }
46 | | {
47 | type: ActionType["DISMISS_TOAST"]
48 | toastId?: ToasterToast["id"]
49 | }
50 | | {
51 | type: ActionType["REMOVE_TOAST"]
52 | toastId?: ToasterToast["id"]
53 | }
54 |
55 | interface State {
56 | toasts: ToasterToast[]
57 | }
58 |
59 | const toastTimeouts = new Map>()
60 |
61 | const addToRemoveQueue = (toastId: string) => {
62 | if (toastTimeouts.has(toastId)) {
63 | return
64 | }
65 |
66 | const timeout = setTimeout(() => {
67 | toastTimeouts.delete(toastId)
68 | dispatch({
69 | type: "REMOVE_TOAST",
70 | toastId: toastId,
71 | })
72 | }, TOAST_REMOVE_DELAY)
73 |
74 | toastTimeouts.set(toastId, timeout)
75 | }
76 |
77 | export const reducer = (state: State, action: Action): State => {
78 | switch (action.type) {
79 | case "ADD_TOAST":
80 | return {
81 | ...state,
82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83 | }
84 |
85 | case "UPDATE_TOAST":
86 | return {
87 | ...state,
88 | toasts: state.toasts.map((t) =>
89 | t.id === action.toast.id ? { ...t, ...action.toast } : t
90 | ),
91 | }
92 |
93 | case "DISMISS_TOAST": {
94 | const { toastId } = action
95 |
96 | // ! Side effects ! - This could be extracted into a dismissToast() action,
97 | // but I'll keep it here for simplicity
98 | if (toastId) {
99 | addToRemoveQueue(toastId)
100 | } else {
101 | state.toasts.forEach((toast) => {
102 | addToRemoveQueue(toast.id)
103 | })
104 | }
105 |
106 | return {
107 | ...state,
108 | toasts: state.toasts.map((t) =>
109 | t.id === toastId || toastId === undefined
110 | ? {
111 | ...t,
112 | open: false,
113 | }
114 | : t
115 | ),
116 | }
117 | }
118 | case "REMOVE_TOAST":
119 | if (action.toastId === undefined) {
120 | return {
121 | ...state,
122 | toasts: [],
123 | }
124 | }
125 | return {
126 | ...state,
127 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
128 | }
129 | }
130 | }
131 |
132 | const listeners: Array<(state: State) => void> = []
133 |
134 | let memoryState: State = { toasts: [] }
135 |
136 | function dispatch(action: Action) {
137 | memoryState = reducer(memoryState, action)
138 | listeners.forEach((listener) => {
139 | listener(memoryState)
140 | })
141 | }
142 |
143 | type Toast = Omit
144 |
145 | function toast({ ...props }: Toast) {
146 | const id = genId()
147 |
148 | const update = (props: ToasterToast) =>
149 | dispatch({
150 | type: "UPDATE_TOAST",
151 | toast: { ...props, id },
152 | })
153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154 |
155 | dispatch({
156 | type: "ADD_TOAST",
157 | toast: {
158 | ...props,
159 | id,
160 | open: true,
161 | onOpenChange: (open) => {
162 | if (!open) dismiss()
163 | },
164 | },
165 | })
166 |
167 | return {
168 | id: id,
169 | dismiss,
170 | update,
171 | }
172 | }
173 |
174 | function useToast() {
175 | const [state, setState] = React.useState(memoryState)
176 |
177 | React.useEffect(() => {
178 | listeners.push(setState)
179 | return () => {
180 | const index = listeners.indexOf(setState)
181 | if (index > -1) {
182 | listeners.splice(index, 1)
183 | }
184 | }
185 | }, [state])
186 |
187 | return {
188 | ...state,
189 | toast,
190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191 | }
192 | }
193 |
194 | export { useToast, toast }
195 |
--------------------------------------------------------------------------------
/src/hooks/useWriteUp.ts:
--------------------------------------------------------------------------------
1 |
2 | "use client";
3 |
4 | import * as React from 'react'; // Changed import
5 | import { WriteUpContext } from '@/contexts/WriteUpContext';
6 |
7 | export const useWriteUp = () => {
8 | const context = React.useContext(WriteUpContext); // Changed useContext
9 | if (context === undefined) {
10 | throw new Error('useWriteUp must be used within a WriteUpProvider');
11 | }
12 | return context;
13 | };
14 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | import type { WriteUp, WriteUpSection, Difficulty, OperatingSystem, SectionType, PdfExportOptions, PdfTheme, PageSize, PageOrientation } from './types';
2 | import { v4 as uuidv4 } from 'uuid';
3 | import { FileText, Flag, HelpCircle, PlaySquare, ListChecks, Users, Server, Brain, FileType as NotesIcon } from 'lucide-react';
4 |
5 | export const DIFFICULTIES_KEYS: Difficulty[] = ['Fácil', 'Medio', 'Difícil', 'Insano', 'Personalizado'];
6 | export const OS_KEYS: OperatingSystem[] = ['Linux', 'Windows', 'macOS', 'Android', 'iOS', 'Otro'];
7 | export const SECTION_TYPES_KEYS: SectionType[] = ['paso', 'pregunta', 'flag', 'notas'];
8 |
9 |
10 | export interface SectionTypeUIDefinition {
11 | value: SectionType;
12 | icon?: React.ElementType;
13 | defaultContent?: string;
14 | }
15 |
16 | export const createDefaultSection = (type: SectionType = 'paso', title?: string, t?: (key: string) => string): WriteUpSection => {
17 | const key = `sectionTypes.${type}.label`;
18 | const defaultTitle = title || (t ? t(key) : key);
19 | const exampleKey = `sectionTypes.${type}.example`;
20 | return {
21 | id: uuidv4(),
22 | type,
23 | title: defaultTitle,
24 | content: t ? t(exampleKey) : `## ${defaultTitle}\n\nContenido de la sección...`,
25 | screenshots: [],
26 | isTemplate: false,
27 | };
28 | };
29 |
30 | export const SECTION_TYPES_DETAILS: Array<{ value: SectionType; icon: React.ElementType }> = [
31 | { value: 'paso', icon: PlaySquare },
32 | { value: 'pregunta', icon: HelpCircle },
33 | { value: 'flag', icon: Flag },
34 | { value: 'notas', icon: NotesIcon },
35 | ];
36 |
37 | export const DEFAULT_SECTIONS_TEMPLATE_KEYS: Array<{ titleKey: string; type: SectionType; contentKey: string }> = [
38 | { titleKey: 'initialReconnaissance', type: 'paso', contentKey: 'initialReconnaissanceContent' },
39 | { titleKey: 'webEnumeration', type: 'paso', contentKey: 'webEnumerationContent' },
40 | { titleKey: 'exploitation', type: 'paso', contentKey: 'exploitationContent' },
41 | { titleKey: 'privilegeEscalation', type: 'paso', contentKey: 'privilegeEscalationContent' },
42 | { titleKey: 'userFlag', type: 'flag', contentKey: 'userFlagContent' },
43 | { titleKey: 'rootFlag', type: 'flag', contentKey: 'rootFlagContent' },
44 | { titleKey: 'mainVulnerability', type: 'pregunta', contentKey: 'mainVulnerabilityContent' },
45 | { titleKey: 'lessonsLearned', type: 'notas', contentKey: 'lessonsLearnedContent' },
46 | ];
47 |
48 |
49 | export const createDefaultWriteUp = (): WriteUp => ({
50 | id: uuidv4(),
51 | title: "", // Changed from translation key to empty string
52 | author: "", // Changed from translation key to empty string
53 | date: new Date().toISOString().split('T')[0],
54 | difficulty: 'Medio',
55 | tags: [],
56 | os: 'Linux',
57 | machineImage: undefined,
58 | sections: DEFAULT_SECTIONS_TEMPLATE_KEYS.map(sec => ({
59 | id: uuidv4(),
60 | type: sec.type,
61 | title: `defaultSections.${sec.titleKey}`,
62 | content: `defaultSectionsContent.${sec.contentKey}`,
63 | screenshots: [],
64 | isTemplate: true,
65 | })),
66 | });
67 |
68 | export const PDF_THEMES_KEYS: PdfTheme[] = ['Hacker', 'Professional-Light', 'Professional-Dark', 'Cyberpunk', 'Minimal'];
69 | export const PDF_PAGE_SIZES: PageSize[] = ['A4', 'Letter'];
70 | export const PDF_PAGE_ORIENTATIONS_KEYS: PageOrientation[] = ['Vertical', 'Horizontal'];
71 |
72 |
73 | export const DEFAULT_PDF_EXPORT_OPTIONS: PdfExportOptions = {
74 | theme: 'Hacker',
75 | includeHeader: true,
76 | includeFooter: true,
77 | pageSize: 'A4',
78 | orientation: 'Vertical',
79 | fontFamily: 'Fira Code',
80 | fontSize: 10,
81 | imageQuality: 90,
82 | autoSplitSections: true,
83 | pageNumbers: true,
84 | headerText: '',
85 | footerText: '',
86 | };
87 |
88 | export const LOCAL_STORAGE_KEY = 'ctfWriteUpBuilder_draft';
89 |
90 | export const getSectionItemIcon = (type: SectionType, titleKey: string = '') => {
91 | const lowerTitleKey = titleKey.toLowerCase();
92 | if (lowerTitleKey.includes('user') || lowerTitleKey.includes('usuario')) return Users;
93 | if (lowerTitleKey.includes('root') || lowerTitleKey.includes('admin')) return Server;
94 | if (lowerTitleKey.includes('recon') || lowerTitleKey.includes('enum')) return Brain;
95 |
96 | const sectionDetail = SECTION_TYPES_DETAILS.find(s => s.value === type);
97 | return sectionDetail?.icon || ListChecks;
98 | };
99 |
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | export type Difficulty = 'Fácil' | 'Medio' | 'Difícil' | 'Insano' | 'Personalizado';
2 | export type OperatingSystem = 'Linux' | 'Windows' | 'macOS' | 'Android' | 'iOS' | 'Otro';
3 | export type SectionType = 'paso' | 'pregunta' | 'flag' | 'notas';
4 |
5 | export interface Screenshot {
6 | id: string;
7 | name: string;
8 | dataUrl: string; // Base64 Data URL
9 | }
10 |
11 | export interface WriteUpSection {
12 | id: string;
13 | type: SectionType;
14 | title: string;
15 | content: string; // Markdown content
16 | answer?: string; // For 'pregunta' type
17 | flagValue?: string; // For 'flag' type
18 | screenshots: Screenshot[];
19 | isTemplate?: boolean; // True if section is from template and not yet modified by user
20 | }
21 |
22 | export interface WriteUp {
23 | id: string; // Added for potential future use (e.g. multiple writeups in a list)
24 | title: string;
25 | author: string;
26 | date: string; // ISO date string
27 | difficulty: Difficulty;
28 | tags: string[];
29 | os: OperatingSystem;
30 | machineImage?: Screenshot; // Single image as Screenshot object
31 | sections: WriteUpSection[];
32 | }
33 |
34 | // For PDF Export Modal
35 | export type PdfTheme = 'Hacker' | 'Professional-Light' | 'Professional-Dark' | 'Cyberpunk' | 'Minimal';
36 | export type PageSize = 'A4' | 'Letter';
37 | export type PageOrientation = 'Vertical' | 'Horizontal';
38 |
39 | export interface PdfExportOptions {
40 | theme: PdfTheme;
41 | includeHeader: boolean;
42 | includeFooter: boolean;
43 | pageSize: PageSize;
44 | orientation: PageOrientation;
45 | fontFamily: string;
46 | fontSize: number;
47 | imageQuality: number; // 0-100
48 | autoSplitSections: boolean;
49 | pageNumbers: boolean;
50 | headerText?: string;
51 | footerText?: string;
52 | }
53 |
54 | // For global view state
55 | export type AppView = 'editor' | 'preview';
56 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/locales/client.ts:
--------------------------------------------------------------------------------
1 | // src/locales/client.ts
2 | 'use client';
3 | import { createI18nClient } from 'next-international/client';
4 |
5 | // Importamos los tipos de los archivos de locale para una mejor inferencia de tipos
6 | // Asegúrate de que tus archivos en y es exporten 'default' y estén marcados con 'as const'
7 | // Ejemplo: export default { greeting: "Hello" } as const;
8 | export const {
9 | useI18n,
10 | useScopedI18n,
11 | I18nProviderClient,
12 | useCurrentLocale,
13 | // Si necesitas cambiar de locale en el cliente, también puedes exportar useChangeLocale
14 | useChangeLocale,
15 | } = createI18nClient({
16 | en: () => import('./en'),
17 | es: () => import('./es'),
18 | });
19 |
--------------------------------------------------------------------------------
/src/locales/i18n.ts:
--------------------------------------------------------------------------------
1 |
2 | // src/locales/i18n.ts
3 | // Este archivo ya no se usará para el proveedor principal ni para los hooks de cliente.
4 | // Se mantiene por si se necesitan las exportaciones de getLocaleProps para
5 | // funcionalidades como generateStaticParams, aunque actualmente no se usa.
6 | // Las funciones de servidor ahora provendrán de server.ts y las de cliente de client.ts.
7 |
8 | // import { createI18n } from 'next-international';
9 | // import type EnLocale from './en';
10 |
11 | // export const {
12 | // // useI18n, // Se moverá a client.ts
13 | // // useScopedI18n, // Se moverá a client.ts
14 | // // I18nProvider, // Se reemplazará por I18nProviderClient de client.ts
15 | // getLocaleProps, // Podría mantenerse si es necesario para funciones de build-time
16 | // // getCurrentLocale, // Se moverá a client.ts y server.ts
17 | // // getScopedI18n, // Se moverá a server.ts
18 | // // getI18n, // Se moverá a server.ts
19 | // } = createI18n({ // Esta llamada es la que causa el error
20 | // en: () => import('./en'),
21 | // es: () => import('./es'),
22 | // });
23 |
24 | // Dejamos el archivo casi vacío para evitar el error,
25 | // o exportando solo lo que no cause conflicto si fuera necesario.
26 | // Por ahora, se recomienda importar directamente de `client.ts` o `server.ts`.
27 |
28 | export {}; // Exportación vacía para que sea tratado como un módulo.
29 |
--------------------------------------------------------------------------------
/src/locales/server.ts:
--------------------------------------------------------------------------------
1 | // src/locales/server.ts
2 | import { createI18nServer } from 'next-international/server';
3 |
4 | export const { getI18n, getScopedI18n, getCurrentLocale } = createI18nServer({
5 | en: () => import('./en'),
6 | es: () => import('./es'),
7 | });
8 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 |
2 | // src/middleware.ts
3 | import { createI18nMiddleware } from 'next-international/middleware';
4 | import type { NextRequest } from 'next/server';
5 |
6 | const I18nMiddleware = createI18nMiddleware({
7 | locales: ['en', 'es'],
8 | defaultLocale: 'es',
9 | urlMappingStrategy: 'rewrite',
10 | });
11 |
12 | export function middleware(request: NextRequest) {
13 | return I18nMiddleware(request);
14 | }
15 |
16 | export const config = {
17 | matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
18 | };
19 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | darkMode: ["class"],
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: 'hsl(var(--background))',
14 | foreground: 'hsl(var(--foreground))',
15 | card: {
16 | DEFAULT: 'hsl(var(--card))',
17 | foreground: 'hsl(var(--card-foreground))'
18 | },
19 | popover: {
20 | DEFAULT: 'hsl(var(--popover))',
21 | foreground: 'hsl(var(--popover-foreground))'
22 | },
23 | primary: {
24 | DEFAULT: 'hsl(var(--primary))',
25 | foreground: 'hsl(var(--primary-foreground))'
26 | },
27 | secondary: {
28 | DEFAULT: 'hsl(var(--secondary))',
29 | foreground: 'hsl(var(--secondary-foreground))'
30 | },
31 | muted: {
32 | DEFAULT: 'hsl(var(--muted))',
33 | foreground: 'hsl(var(--muted-foreground))'
34 | },
35 | accent: {
36 | DEFAULT: 'hsl(var(--accent))',
37 | foreground: 'hsl(var(--accent-foreground))'
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))'
42 | },
43 | border: 'hsl(var(--border))',
44 | input: 'hsl(var(--input))',
45 | ring: 'hsl(var(--ring))',
46 | chart: {
47 | '1': 'hsl(var(--chart-1))',
48 | '2': 'hsl(var(--chart-2))',
49 | '3': 'hsl(var(--chart-3))',
50 | '4': 'hsl(var(--chart-4))',
51 | '5': 'hsl(var(--chart-5))'
52 | },
53 | sidebar: {
54 | DEFAULT: 'hsl(var(--sidebar-background))',
55 | foreground: 'hsl(var(--sidebar-foreground))',
56 | primary: 'hsl(var(--sidebar-primary))',
57 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
58 | accent: 'hsl(var(--sidebar-accent))',
59 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
60 | border: 'hsl(var(--sidebar-border))',
61 | ring: 'hsl(var(--sidebar-ring))'
62 | }
63 | },
64 | borderRadius: {
65 | lg: 'var(--radius)',
66 | md: 'calc(var(--radius) - 2px)',
67 | sm: 'calc(var(--radius) - 4px)'
68 | },
69 | keyframes: {
70 | 'accordion-down': {
71 | from: {
72 | height: '0'
73 | },
74 | to: {
75 | height: 'var(--radix-accordion-content-height)'
76 | }
77 | },
78 | 'accordion-up': {
79 | from: {
80 | height: 'var(--radix-accordion-content-height)'
81 | },
82 | to: {
83 | height: '0'
84 | }
85 | }
86 | },
87 | animation: {
88 | 'accordion-down': 'accordion-down 0.2s ease-out',
89 | 'accordion-up': 'accordion-up 0.2s ease-out'
90 | }
91 | }
92 | },
93 | plugins: [require("tailwindcss-animate")],
94 | } satisfies Config;
95 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------