Settings: prompts como tarjetas + modal editor TipTap (negrita, listas, código, etc.)

- PromptEditorModal.vue: nuevo componente con editor TipTap rich text
  toolbar: bold, italic, headings, bullet/ordered lists, code, quote, undo/redo
- SettingsView: prompts se muestran como tarjetas con preview y botón editar
  clic abre modal con editor completo + guardar/restaurar
- Instalado @tiptap/vue-3 con starter-kit y extensiones (placeholder, code, link, table)
This commit is contained in:
2026-05-30 00:48:21 -05:00
parent b21214d1f1
commit 97950adf8b
6 changed files with 3050 additions and 46 deletions
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Bold, Italic, List, ListOrdered, Code, Heading, Quote, Undo, Redo } from 'lucide-vue-next'
const props = defineProps<{
open: boolean
title: string
content: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
save: [content: string]
reset: []
}>()
const editor = useEditor({
content: props.content,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({ placeholder: 'Editá el prompt...' }),
],
onUpdate: () => {
currentContent.value = editor.value?.getHTML() || ''
},
})
const currentContent = ref(props.content)
watch(() => props.content, (val) => {
currentContent.value = val
editor.value?.commands.setContent(val)
})
watch(() => props.open, (open) => {
if (open) {
setTimeout(() => editor.value?.commands.setContent(props.content), 50)
}
})
function handleSave() {
const html = editor.value?.getHTML() || ''
// Convert HTML to plain text for storage (los prompts son texto plano)
const text = html
.replace(/<p>/g, '')
.replace(/<\/p>/g, '\n')
.replace(/<br\s*\/?>/g, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\n{3,}/g, '\n\n')
.trim()
emit('save', text)
emit('update:open', false)
}
function toggleBold() { editor.value?.chain().focus().toggleBold().run() }
function toggleItalic() { editor.value?.chain().focus().toggleItalic().run() }
function toggleBulletList() { editor.value?.chain().focus().toggleBulletList().run() }
function toggleOrderedList() { editor.value?.chain().focus().toggleOrderedList().run() }
function toggleCode() { editor.value?.chain().focus().toggleCodeBlock().run() }
function toggleHeading(level: 1 | 2 | 3) { editor.value?.chain().focus().toggleHeading({ level }).run() }
function toggleBlockquote() { editor.value?.chain().focus().toggleBlockquote().run() }
function undo() { editor.value?.chain().focus().undo().run() }
function redo() { editor.value?.chain().focus().redo().run() }
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-[800px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
</DialogHeader>
<!-- Toolbar -->
<div class="flex items-center gap-0.5 p-1 rounded-lg bg-muted/50 border mb-2 flex-wrap">
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleBold" title="Negrita"><Bold class="size-3.5" /></button>
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleItalic" title="Cursiva"><Italic class="size-3.5" /></button>
<span class="w-px h-5 bg-border mx-0.5" />
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleHeading(1)" title="H1"><Heading class="size-3.5" /><sup class="text-[8px]">1</sup></button>
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleHeading(2)" title="H2"><Heading class="size-3.5" /><sup class="text-[8px]">2</sup></button>
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleHeading(3)" title="H3"><Heading class="size-3.5" /><sup class="text-[8px]">3</sup></button>
<span class="w-px h-5 bg-border mx-0.5" />
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleBulletList" title="Lista"><List class="size-3.5" /></button>
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleOrderedList" title="Lista numerada"><ListOrdered class="size-3.5" /></button>
<span class="w-px h-5 bg-border mx-0.5" />
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleCode" title="Código"><Code class="size-3.5" /></button>
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="toggleBlockquote" title="Cita"><Quote class="size-3.5" /></button>
<span class="w-px h-5 bg-border mx-0.5" />
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="undo" title="Deshacer"><Undo class="size-3.5" /></button>
<button class="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors" @click="redo" title="Rehacer"><Redo class="size-3.5" /></button>
</div>
<!-- Editor -->
<div class="flex-1 overflow-y-auto min-h-[300px] rounded-lg border p-4 text-sm">
<EditorContent :editor="editor" class="prose prose-sm dark:prose-invert max-w-none focus:outline-none" />
</div>
<!-- Footer -->
<div class="flex items-center justify-between pt-3 border-t mt-2">
<Button size="sm" variant="ghost" class="text-xs text-muted-foreground" @click="emit('reset')">
Restaurar default
</Button>
<div class="flex gap-2">
<Button size="sm" variant="outline" class="text-xs" @click="emit('update:open', false)">Cancelar</Button>
<Button size="sm" class="text-xs" @click="handleSave">Guardar</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<style>
.tiptap {
outline: none;
min-height: 250px;
}
.tiptap p.is-editor-empty:first-child::before {
color: hsl(var(--muted-foreground));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
opacity: 0.5;
}
.tiptap pre {
background: hsl(var(--muted));
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
overflow-x: auto;
}
.tiptap blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 1rem;
margin: 0.5rem 0;
color: hsl(var(--muted-foreground));
}
.tiptap ul, .tiptap ol {
padding-left: 1.5rem;
}
.tiptap h1 { font-size: 1.25rem; font-weight: 700; margin: 0.75rem 0 0.5rem; }
.tiptap h2 { font-size: 1.1rem; font-weight: 600; margin: 0.5rem 0 0.25rem; }
.tiptap h3 { font-size: 1rem; font-weight: 600; margin: 0.5rem 0 0.25rem; }
</style>
+67 -30
View File
@@ -3,7 +3,8 @@ import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingsStore, PROVIDER_CONFIG, type AIProvider } from '@/stores/settings'
import { useAuthStore } from '@/stores/auth'
import { getAllPromptKeys, getPrompt, savePrompt, resetPrompt, type PromptKey } from '@/services/prompts-db'
import { getAllPromptKeys, getPrompt, savePrompt, resetPrompt, getDefaultPrompt, type PromptKey } from '@/services/prompts-db'
import PromptEditorModal from '@/components/PromptEditorModal.vue'
import {
Card,
CardContent,
@@ -68,6 +69,32 @@ function markDirty(key: string) {
promptDirty.value[key] = true
}
// ─── Prompt editor modal ──────────────────────────
const editingPrompt = ref<{ key: PromptKey; label: string } | null>(null)
const editingContent = ref('')
const promptModalOpen = ref(false)
function openPromptEditor(key: PromptKey, label: string) {
editingPrompt.value = { key, label }
editingContent.value = promptContents.value[key] || ''
promptModalOpen.value = true
}
function savePromptFromEditor(content: string) {
if (!editingPrompt.value) return
const key = editingPrompt.value.key
promptContents.value[key] = content
promptDirty.value[key] = true
savePromptHandler(key)
}
function resetPromptFromEditor() {
if (!editingPrompt.value) return
const key = editingPrompt.value.key
resetPromptHandler(key)
editingContent.value = promptContents.value[key] || ''
}
onMounted(loadPrompts)
// ─── Teams ───────────────────────────────────────────
@@ -361,40 +388,50 @@ const tierColors: Record<string, string> = {
Editá los prompts que usa la IA para cada función. Los cambios se aplican inmediatamente.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div v-for="{ key, label } in promptKeys" :key="key" class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-xs font-medium">{{ label }}</Label>
<div class="flex gap-1">
<Button
v-if="promptDirty[key]"
size="sm"
variant="default"
class="text-xs h-6 px-2"
:disabled="promptSaving[key]"
@click="savePromptHandler(key as PromptKey)"
>
{{ promptSaving[key] ? 'Guardando...' : 'Guardar' }}
</Button>
<Button
size="sm"
variant="ghost"
class="text-xs h-6 px-2 text-muted-foreground"
@click="resetPromptHandler(key as PromptKey)"
>
Restaurar
</Button>
</div>
<CardContent class="space-y-2">
<div
v-for="{ key, label } in promptKeys"
:key="key"
class="flex items-center gap-3 p-3 rounded-lg border hover:border-primary/50 transition-colors cursor-pointer"
@click="openPromptEditor(key as PromptKey, label)"
>
<div class="size-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<span class="text-xs font-bold text-muted-foreground">{{ label.slice(0, 2).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">{{ label }}</p>
<p class="text-xs text-muted-foreground truncate mt-0.5">{{ (promptContents[key] || '').slice(0, 80) }}...</p>
</div>
<div class="flex gap-1 shrink-0">
<Button
v-if="promptDirty[key]"
size="sm"
variant="default"
class="text-xs h-7 px-2"
:disabled="promptSaving[key]"
@click.stop="savePromptHandler(key as PromptKey)"
>
{{ promptSaving[key] ? '...' : 'Guardar' }}
</Button>
<Button size="sm" variant="ghost" class="text-xs h-7 px-2 text-muted-foreground" @click.stop="openPromptEditor(key as PromptKey, label)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
</Button>
</div>
<textarea
:value="promptContents[key]"
@input="(e: any) => { promptContents[key] = e.target.value; markDirty(key) }"
class="w-full h-32 rounded-md border border-input bg-background p-2 text-xs font-mono outline-none focus:border-ring focus:ring-1 focus:ring-ring resize-y"
/>
</div>
<p class="text-[10px] text-muted-foreground text-center pt-1">Haz clic en un prompt para editarlo con el editor enriquecido</p>
</CardContent>
</Card>
<!-- Prompt Editor Modal -->
<PromptEditorModal
:open="promptModalOpen"
:title="editingPrompt?.label || ''"
:content="editingContent"
@update:open="promptModalOpen = $event"
@save="savePromptFromEditor"
@reset="resetPromptFromEditor"
/>
<!-- Account -->
<Card id="settings-account">
<CardHeader>