@innertia-solutions/innertia-nuxt 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/app.config.ts +10 -10
  3. package/components/Attention.vue +269 -0
  4. package/components/Auth/LoginForm.vue +1 -1
  5. package/components/Chart/index.vue +193 -0
  6. package/components/Container.vue +116 -0
  7. package/components/CopyButton.vue +98 -0
  8. package/components/Files/Card.vue +215 -0
  9. package/components/Files/Preview.vue +675 -0
  10. package/components/Files/Upload.vue +408 -0
  11. package/components/Files/UploadModal.vue +83 -0
  12. package/components/Forms/InputPin.vue +162 -0
  13. package/components/InfoToolbar.vue +54 -0
  14. package/components/Kanban/Standard.vue +588 -0
  15. package/components/Loader.vue +358 -0
  16. package/components/MobileBlocker.vue +5 -5
  17. package/components/MobileLoginPicker.vue +13 -13
  18. package/components/Modal/Base.vue +6 -0
  19. package/components/Modal/Confirm.vue +89 -0
  20. package/components/Modal/DeleteConfirm.vue +14 -31
  21. package/components/Modal/Form.vue +76 -0
  22. package/components/Modal/Host.vue +58 -0
  23. package/components/Modal/Prompt.vue +74 -0
  24. package/components/Modal.vue +119 -34
  25. package/components/Organization/Picker.vue +3 -3
  26. package/components/Table/Standard.vue +198 -25
  27. package/components/Table/index.vue +169 -369
  28. package/components/Table/views/ViewCards.vue +74 -0
  29. package/components/Table/views/ViewDatabase.vue +236 -0
  30. package/components/Table/views/ViewTable.vue +299 -0
  31. package/components/Toast/Alert.vue +16 -111
  32. package/components/Toast/Container.vue +4 -29
  33. package/components/Toast/Notification.vue +8 -43
  34. package/components/Toast/Process.vue +8 -86
  35. package/components/Toast/Stack.vue +59 -0
  36. package/components/Toast/Standard.vue +115 -0
  37. package/components/Tree/Standard.vue +49 -4
  38. package/components/Workflow/Board.vue +356 -0
  39. package/components/Workflow/Builder.vue +454 -0
  40. package/components/Workflow/Form.vue +526 -0
  41. package/components/Workflow/Viewer.vue +225 -0
  42. package/components/Workflow/edges/TransitionEdge.vue +84 -0
  43. package/components/Workflow/nodes/StepNode.vue +88 -0
  44. package/composables/useAuth.js +1 -1
  45. package/composables/useClipboard.js +79 -0
  46. package/composables/useConfetti.js +194 -0
  47. package/composables/useContext.js +53 -5
  48. package/composables/useFile.js +189 -0
  49. package/composables/useMobileGuard.ts +21 -21
  50. package/composables/useModal.js +71 -0
  51. package/composables/useOrganization.ts +3 -3
  52. package/composables/useToast.js +38 -65
  53. package/composables/useWorkflow/form.js +162 -0
  54. package/composables/useWorkflow/index.js +18 -0
  55. package/composables/useWorkflow/view.js +220 -0
  56. package/composables/useWorkflowLayout.js +162 -0
  57. package/css/tokens.css +10 -10
  58. package/docs/superpowers/plans/2026-05-27-nuxt-apps-to-contexts-rename.md +630 -0
  59. package/docs/superpowers/specs/2026-05-27-nuxt-apps-to-contexts-rename-design.md +113 -0
  60. package/middleware/{03.apps.global.ts → 03.contexts.global.ts} +25 -25
  61. package/nuxt.config.ts +1 -1
  62. package/package.json +13 -8
  63. package/stores/modal.js +48 -0
  64. package/stores/toast.js +156 -117
  65. package/composables/useApp.ts +0 -46
@@ -0,0 +1,225 @@
1
+ <script setup>
2
+ import { ref, computed, markRaw, watch } from 'vue'
3
+ import { VueFlow, Panel } from '@vue-flow/core'
4
+ import { Background } from '@vue-flow/background'
5
+ import { Controls } from '@vue-flow/controls'
6
+ import StepNode from './nodes/StepNode.vue'
7
+ import TransitionEdge from './edges/TransitionEdge.vue'
8
+ import { definitionToFlow } from '~/composables/useWorkflowLayout.js'
9
+
10
+ // ── Props & emits ─────────────────────────────────────────────────────────────
11
+ const props = defineProps({
12
+ /** WorkflowDefinition.config — { steps:[], transitions:[] } */
13
+ definition: { type: Object, required: true },
14
+ /** current_step key */
15
+ currentStep: { type: String, default: null },
16
+ /** array de step keys disponibles para transicionar */
17
+ availableTransitions: { type: Array, default: () => [] },
18
+ /** array de { from_step, to_step, performed_by, performed_at, notes } */
19
+ history: { type: Array, default: () => [] },
20
+ /** 'flow' | 'list' | 'history' */
21
+ defaultView: { type: String, default: 'flow' },
22
+ })
23
+
24
+ const emit = defineEmits(['transition'])
25
+
26
+ // ── Vue Flow types ────────────────────────────────────────────────────────────
27
+ const nodeTypes = markRaw({ step: StepNode })
28
+ const edgeTypes = markRaw({ transition: TransitionEdge })
29
+
30
+ // ── Reactive nodes/edges — re-calculados cuando cambia currentStep o definition
31
+ const flowNodes = ref([])
32
+ const flowEdges = ref([])
33
+
34
+ const rebuildFlow = () => {
35
+ const { nodes: n, edges: e } = definitionToFlow(props.definition)
36
+
37
+ flowNodes.value = n.map(node => ({
38
+ ...node,
39
+ selectable: false,
40
+ draggable: false,
41
+ class: [
42
+ node.id === props.currentStep ? 'vf-current' : '',
43
+ props.availableTransitions.includes(node.id) ? 'vf-available' : '',
44
+ ].filter(Boolean).join(' '),
45
+ data: {
46
+ ...node.data,
47
+ isCurrent: node.id === props.currentStep,
48
+ isAvailable: props.availableTransitions.includes(node.id),
49
+ },
50
+ }))
51
+
52
+ flowEdges.value = e
53
+ }
54
+
55
+ watch(
56
+ [() => props.definition, () => props.currentStep, () => props.availableTransitions],
57
+ rebuildFlow,
58
+ { immediate: true, deep: true },
59
+ )
60
+
61
+ // ── View mode ─────────────────────────────────────────────────────────────────
62
+ const activeView = ref(props.defaultView)
63
+
64
+ // ── List helpers ──────────────────────────────────────────────────────────────
65
+ const STEP_TYPE_LABELS = {
66
+ start: 'Inicio',
67
+ in_progress: 'En progreso',
68
+ pause_internal: 'Pausa interna',
69
+ pause_external: 'Pausa externa',
70
+ finished: 'Finalizado',
71
+ cancelled: 'Cancelado',
72
+ }
73
+
74
+ const stepsWithState = computed(() =>
75
+ (props.definition.steps ?? []).map(step => ({
76
+ ...step,
77
+ isCurrent: step.key === props.currentStep,
78
+ isAvailable: props.availableTransitions.includes(step.key),
79
+ isPast: (props.history ?? []).some(h => h.to_step === step.key),
80
+ }))
81
+ )
82
+
83
+ const formatDate = (iso) =>
84
+ iso
85
+ ? new Date(iso).toLocaleString('es-CL', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
86
+ : '—'
87
+ </script>
88
+
89
+ <template>
90
+ <div class="flex flex-col h-full min-h-0">
91
+
92
+ <!-- Toolbar -->
93
+ <div class="flex items-center justify-between gap-3 px-3 py-2 border-b border-card-line bg-surface shrink-0">
94
+ <div class="inline-flex items-center rounded-control border border-card-line bg-card overflow-hidden">
95
+ <button
96
+ v-for="mode in [{ id: 'flow', label: 'Flujo' }, { id: 'list', label: 'Lista' }, { id: 'history', label: 'Historial' }]"
97
+ :key="mode.id"
98
+ type="button"
99
+ @click="activeView = mode.id"
100
+ :class="[
101
+ 'px-3 py-1.5 text-xs font-medium transition-colors',
102
+ activeView === mode.id ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted-hover',
103
+ ]"
104
+ >
105
+ {{ mode.label }}
106
+ </button>
107
+ </div>
108
+
109
+ <div class="flex items-center gap-3 text-xs text-muted-foreground">
110
+ <span class="flex items-center gap-1.5">
111
+ <span class="w-2.5 h-2.5 rounded-full border-2 border-primary bg-primary/20" />
112
+ Actual
113
+ </span>
114
+ <span class="flex items-center gap-1.5">
115
+ <span class="w-2.5 h-2.5 rounded-full border-2 border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30" />
116
+ Disponible
117
+ </span>
118
+ </div>
119
+ </div>
120
+
121
+ <!-- Content -->
122
+ <div class="flex-1 min-h-0 relative overflow-hidden">
123
+
124
+ <!-- ── FLOW VIEW ──────────────────────────────────────────────────────── -->
125
+ <div v-if="activeView === 'flow'" class="h-full">
126
+ <VueFlow
127
+ :nodes="flowNodes"
128
+ :edges="flowEdges"
129
+ :node-types="nodeTypes"
130
+ :edge-types="edgeTypes"
131
+ :nodes-draggable="false"
132
+ :nodes-connectable="false"
133
+ :elements-selectable="false"
134
+ fit-view-on-init
135
+ class="h-full bg-background"
136
+ >
137
+ <Background pattern-color="var(--color-card-line)" :gap="20" />
138
+ <Controls :show-interactive="false" />
139
+
140
+ <Panel position="bottom-center">
141
+ <div v-if="availableTransitions.length" class="flex items-center gap-2 flex-wrap justify-center pb-2">
142
+ <button
143
+ v-for="stepKey in availableTransitions"
144
+ :key="stepKey"
145
+ type="button"
146
+ @click="emit('transition', stepKey)"
147
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground hover:bg-primary-hover rounded-control shadow transition-colors"
148
+ >
149
+ Ir a {{ definition.steps?.find(s => s.key === stepKey)?.label ?? stepKey }}
150
+ </button>
151
+ </div>
152
+ </Panel>
153
+ </VueFlow>
154
+ </div>
155
+
156
+ <!-- ── LIST VIEW ──────────────────────────────────────────────────────── -->
157
+ <div v-if="activeView === 'list'" class="h-full overflow-y-auto p-4">
158
+ <div class="relative pl-6 space-y-1 before:absolute before:left-[7px] before:top-3 before:bottom-3 before:w-px before:bg-card-line">
159
+ <div
160
+ v-for="step in stepsWithState"
161
+ :key="step.key"
162
+ :class="[
163
+ 'relative flex items-start gap-3 pl-4 py-3 rounded-card transition-colors',
164
+ step.isCurrent ? 'bg-primary/5 border border-primary/20' : '',
165
+ step.isAvailable && !step.isCurrent ? 'bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-400/30' : '',
166
+ !step.isCurrent && !step.isAvailable ? 'opacity-60' : '',
167
+ ]"
168
+ >
169
+ <span
170
+ :class="[
171
+ 'absolute -left-[3px] top-4 w-3 h-3 rounded-full border-2 shrink-0',
172
+ step.isCurrent ? 'border-primary bg-primary' : '',
173
+ step.isAvailable && !step.isCurrent ? 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/50' : '',
174
+ !step.isCurrent && !step.isAvailable ? 'border-card-line bg-background' : '',
175
+ ]"
176
+ />
177
+ <div class="flex-1 min-w-0">
178
+ <p class="text-sm font-medium text-foreground">{{ step.label }}</p>
179
+ <p class="text-xs text-muted-foreground font-mono">{{ step.key }} · {{ STEP_TYPE_LABELS[step.type] }}</p>
180
+ </div>
181
+ <div class="flex items-center gap-2 shrink-0">
182
+ <span v-if="step.isCurrent" class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-badge bg-primary/10 text-primary">
183
+ Actual
184
+ </span>
185
+ <button
186
+ v-if="step.isAvailable && !step.isCurrent"
187
+ type="button"
188
+ @click="emit('transition', step.key)"
189
+ class="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-badge bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20 transition-colors"
190
+ >
191
+ Transicionar
192
+ </button>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+
198
+ <!-- ── HISTORY ────────────────────────────────────────────────────────── -->
199
+ <div v-if="activeView === 'history'" class="h-full overflow-y-auto p-4">
200
+ <div v-if="history.length" class="space-y-2">
201
+ <div
202
+ v-for="(entry, idx) in [...history].reverse()"
203
+ :key="idx"
204
+ class="flex items-start gap-3 bg-card rounded-card border border-card-line px-4 py-3"
205
+ >
206
+ <div class="w-2 h-2 rounded-full bg-primary/60 shrink-0 mt-1.5" />
207
+ <div class="flex-1 min-w-0">
208
+ <p class="text-xs text-foreground">
209
+ <span class="font-mono text-muted-foreground">{{ entry.from_step }}</span>
210
+ <span class="mx-1.5 text-muted-foreground-2">→</span>
211
+ <span class="font-mono font-medium">{{ entry.to_step }}</span>
212
+ </p>
213
+ <p v-if="entry.notes" class="text-xs text-muted-foreground mt-0.5 italic">{{ entry.notes }}</p>
214
+ </div>
215
+ <p class="text-[11px] text-muted-foreground shrink-0">{{ formatDate(entry.performed_at) }}</p>
216
+ </div>
217
+ </div>
218
+ <div v-else class="flex items-center justify-center py-12 text-sm text-muted-foreground">
219
+ Sin historial de transiciones
220
+ </div>
221
+ </div>
222
+
223
+ </div>
224
+ </div>
225
+ </template>
@@ -0,0 +1,84 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { getBezierPath, EdgeLabelRenderer, BaseEdge } from '@vue-flow/core'
4
+
5
+ defineOptions({ inheritAttrs: false })
6
+
7
+ const props = defineProps({
8
+ id: String,
9
+ sourceX: Number,
10
+ sourceY: Number,
11
+ targetX: Number,
12
+ targetY: Number,
13
+ sourcePosition: String,
14
+ targetPosition: String,
15
+ data: { type: Object, default: () => ({}) },
16
+ label: { type: String, default: '' },
17
+ selected: { type: Boolean, default: false },
18
+ markerEnd: String,
19
+ })
20
+
21
+ // ── Path reactivo — recalculado cuando cambian las posiciones ─────────────────
22
+ const pathData = computed(() =>
23
+ getBezierPath({
24
+ sourceX: props.sourceX,
25
+ sourceY: props.sourceY,
26
+ sourcePosition: props.sourcePosition,
27
+ targetX: props.targetX,
28
+ targetY: props.targetY,
29
+ targetPosition: props.targetPosition,
30
+ })
31
+ )
32
+
33
+ const edgePath = computed(() => pathData.value[0])
34
+ const labelX = computed(() => pathData.value[1])
35
+ const labelY = computed(() => pathData.value[2])
36
+
37
+ const hasRestrictions = computed(() => (props.data?.restrictions?.length ?? 0) > 0)
38
+ const displayLabel = computed(() => props.label || props.data?.label || '')
39
+ const isBackEdge = computed(() => props.data?.isBackEdge === true)
40
+ </script>
41
+
42
+ <template>
43
+ <BaseEdge
44
+ :id="id"
45
+ :path="edgePath"
46
+ :marker-end="markerEnd"
47
+ :style="{
48
+ stroke: selected ? 'var(--color-primary)' : isBackEdge ? 'var(--color-muted-foreground)' : 'var(--color-card-line)',
49
+ strokeWidth: selected ? 2 : 1.5,
50
+ strokeDasharray: isBackEdge ? '5 3' : 'none',
51
+ opacity: isBackEdge && !selected ? 0.65 : 1,
52
+ }"
53
+ />
54
+
55
+ <EdgeLabelRenderer v-if="displayLabel || hasRestrictions">
56
+ <div
57
+ :style="{
58
+ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
59
+ pointerEvents: 'all',
60
+ }"
61
+ class="absolute nodrag nopan"
62
+ >
63
+ <div
64
+ :class="[
65
+ 'flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border transition-colors',
66
+ selected
67
+ ? 'bg-primary text-primary-foreground border-primary'
68
+ : 'bg-card text-foreground border-card-line hover:border-primary/50',
69
+ ]"
70
+ >
71
+ <span v-if="displayLabel">{{ displayLabel }}</span>
72
+ <span
73
+ v-if="hasRestrictions"
74
+ :class="[
75
+ 'inline-flex items-center justify-center w-4 h-4 rounded-full text-[9px] font-bold',
76
+ selected ? 'bg-primary-foreground/20 text-primary-foreground' : 'bg-muted text-muted-foreground',
77
+ ]"
78
+ >
79
+ {{ data.restrictions.length }}
80
+ </span>
81
+ </div>
82
+ </div>
83
+ </EdgeLabelRenderer>
84
+ </template>
@@ -0,0 +1,88 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { Handle, Position } from '@vue-flow/core'
4
+
5
+ defineOptions({ inheritAttrs: false })
6
+
7
+ const props = defineProps({
8
+ data: { type: Object, required: true },
9
+ selected: { type: Boolean, default: false },
10
+ })
11
+
12
+ const STEP_CONFIG = {
13
+ start: { label: 'Inicio', color: 'emerald', dot: 'bg-emerald-500', border: 'border-emerald-400 dark:border-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/30' },
14
+ in_progress: { label: 'En progreso', color: 'blue', dot: 'bg-blue-500', border: 'border-blue-400 dark:border-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/30' },
15
+ pause_internal: { label: 'Pausa int.', color: 'amber', dot: 'bg-amber-500', border: 'border-amber-400 dark:border-amber-600', bg: 'bg-amber-50 dark:bg-amber-900/30' },
16
+ pause_external: { label: 'Pausa ext.', color: 'orange', dot: 'bg-orange-500', border: 'border-orange-400 dark:border-orange-600', bg: 'bg-orange-50 dark:bg-orange-900/30' },
17
+ finished: { label: 'Finalizado', color: 'violet', dot: 'bg-violet-500', border: 'border-violet-400 dark:border-violet-600', bg: 'bg-violet-50 dark:bg-violet-900/30' },
18
+ cancelled: { label: 'Cancelado', color: 'red', dot: 'bg-red-500', border: 'border-red-400 dark:border-red-600', bg: 'bg-red-50 dark:bg-red-900/30' },
19
+ }
20
+
21
+ const cfg = computed(() => STEP_CONFIG[props.data.type] ?? STEP_CONFIG.in_progress)
22
+ const isStart = computed(() => props.data.type === 'start')
23
+ const isTerminal = computed(() => ['finished', 'cancelled'].includes(props.data.type))
24
+ </script>
25
+
26
+ <template>
27
+ <div
28
+ :class="[
29
+ 'relative flex flex-col gap-1 rounded-lg border-2 px-3 py-2.5 w-52 bg-card shadow-sm transition-shadow',
30
+ cfg.border,
31
+ selected ? 'shadow-md ring-2 ring-primary/50 ring-offset-1' : 'hover:shadow-md',
32
+ ]"
33
+ >
34
+ <!-- Handles invisibles en Top — para rutear back-edges por encima del nodo -->
35
+ <Handle
36
+ id="top-target"
37
+ type="target"
38
+ :position="Position.Top"
39
+ style="opacity:0; pointer-events:none; width:6px; height:6px;"
40
+ />
41
+ <Handle
42
+ id="top-source"
43
+ type="source"
44
+ :position="Position.Top"
45
+ style="opacity:0; pointer-events:none; width:6px; height:6px;"
46
+ />
47
+
48
+ <!-- Target handle (no mostrar para nodo start) -->
49
+ <Handle
50
+ v-if="!isStart"
51
+ type="target"
52
+ :position="Position.Left"
53
+ class="!bg-card-line !border-card-line !w-3 !h-3 hover:!bg-primary transition-colors"
54
+ />
55
+
56
+ <!-- Header: dot + type badge -->
57
+ <div class="flex items-center gap-2">
58
+ <span :class="['w-2 h-2 rounded-full shrink-0', cfg.dot]" />
59
+ <span
60
+ :class="[
61
+ 'text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-full',
62
+ cfg.bg, 'text-foreground',
63
+ ]"
64
+ >
65
+ {{ cfg.label }}
66
+ </span>
67
+ </div>
68
+
69
+ <!-- Label -->
70
+ <p class="text-sm font-medium text-foreground leading-tight truncate">{{ data.label }}</p>
71
+
72
+ <!-- Key -->
73
+ <p class="text-[11px] text-muted-foreground font-mono truncate">{{ data.key }}</p>
74
+
75
+ <!-- Description (if present) -->
76
+ <p v-if="data.description" class="text-[11px] text-muted-foreground leading-tight line-clamp-2 mt-0.5">
77
+ {{ data.description }}
78
+ </p>
79
+
80
+ <!-- Source handle (no mostrar para terminales) -->
81
+ <Handle
82
+ v-if="!isTerminal"
83
+ type="source"
84
+ :position="Position.Right"
85
+ class="!bg-card-line !border-card-line !w-3 !h-3 hover:!bg-primary transition-colors"
86
+ />
87
+ </div>
88
+ </template>
@@ -14,7 +14,7 @@ export function useAuth() {
14
14
  */
15
15
  async function performLogin(context, email, password, remember = false) {
16
16
  authStore.rememberUser = remember
17
- const data = await api.post(`${context}/auth/login`, { email, password, app: context })
17
+ const data = await api.post(`${context}/auth/login`, { email, password, context })
18
18
  authStore.saveToken(data.token ?? data.access_token)
19
19
  authStore.setCurrentContext(context)
20
20
  queryClient.clear()
@@ -0,0 +1,79 @@
1
+ /**
2
+ * useClipboard — Wrapper sobre navigator.clipboard con estado reactivo
3
+ * `copied` que vuelve a false después de `timeout` ms.
4
+ *
5
+ * Hace fallback a un truco con `document.execCommand('copy')` cuando la
6
+ * Clipboard API no está disponible (HTTP, iframes restringidos, etc.).
7
+ *
8
+ * ```js
9
+ * const { copy, copied, supported } = useClipboard({ timeout: 1500 })
10
+ *
11
+ * <button @click="copy('hola mundo')">
12
+ * {{ copied ? '¡Copiado!' : 'Copiar' }}
13
+ * </button>
14
+ * ```
15
+ *
16
+ * También expone `text` para usar como modelo si querés bindear automáticamente
17
+ * un valor que cambia (ej. un input controlado).
18
+ */
19
+ export function useClipboard(options = {}) {
20
+ const timeout = options.timeout ?? 1500
21
+
22
+ const text = ref('')
23
+ const copied = ref(false)
24
+ const error = ref(null)
25
+ const supported = computed(() => {
26
+ if (typeof navigator === 'undefined') return false
27
+ return !!(navigator.clipboard && navigator.clipboard.writeText)
28
+ })
29
+
30
+ let timeoutId = null
31
+
32
+ const _resetSoon = () => {
33
+ if (timeoutId) clearTimeout(timeoutId)
34
+ timeoutId = setTimeout(() => {
35
+ copied.value = false
36
+ error.value = null
37
+ }, timeout)
38
+ }
39
+
40
+ const _legacy = (value) => {
41
+ if (typeof document === 'undefined') return false
42
+ const el = document.createElement('textarea')
43
+ el.value = value
44
+ el.setAttribute('readonly', '')
45
+ el.style.position = 'fixed'
46
+ el.style.opacity = '0'
47
+ document.body.appendChild(el)
48
+ el.select()
49
+ let ok = false
50
+ try { ok = document.execCommand('copy') } catch (_) { ok = false }
51
+ document.body.removeChild(el)
52
+ return ok
53
+ }
54
+
55
+ /** Copia el string al clipboard. Retorna Promise<boolean>. */
56
+ const copy = async (value) => {
57
+ const str = String(value ?? '')
58
+ text.value = str
59
+ error.value = null
60
+
61
+ try {
62
+ if (supported.value) {
63
+ await navigator.clipboard.writeText(str)
64
+ } else {
65
+ const ok = _legacy(str)
66
+ if (!ok) throw new Error('No se pudo copiar')
67
+ }
68
+ copied.value = true
69
+ _resetSoon()
70
+ return true
71
+ } catch (e) {
72
+ error.value = e?.message ?? 'Error al copiar'
73
+ copied.value = false
74
+ return false
75
+ }
76
+ }
77
+
78
+ return { copy, copied, error, text, supported }
79
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * useConfetti — Animación de confetti zero-deps en canvas overlay.
3
+ *
4
+ * ```js
5
+ * const { burst, fireworks, cannon, schoolPride, stop } = useConfetti()
6
+ *
7
+ * burst() // explosión simple en el centro
8
+ * burst({ origin: { x: 0.5, y: 0.3 } }) // explosión desde un punto (0-1)
9
+ * burst({ colors: ['#ff0', '#0f0'] }) // colores custom
10
+ * fireworks({ duration: 3000 }) // fuegos artificiales 3s
11
+ * cannon({ angle: 60, spread: 55 }) // un solo "cañon"
12
+ * schoolPride(['#ff0066', '#33ccff']) // dos cañones laterales
13
+ * ```
14
+ *
15
+ * El canvas se monta una sola vez en <body>, full-screen, pointer-events: none,
16
+ * z-index altísimo. Se limpia automáticamente cuando no hay partículas.
17
+ */
18
+
19
+ const DEFAULT_COLORS = ['#FF3E7F', '#FFC23C', '#3CDBFF', '#7B61FF', '#27D67B', '#FF7A45']
20
+ const GRAVITY = 0.18
21
+ const DRAG = 0.012
22
+
23
+ let canvas = null
24
+ let ctx = null
25
+ let particles = []
26
+ let rafId = null
27
+ let resizeHandler = null
28
+
29
+ function ensureCanvas() {
30
+ if (typeof window === 'undefined') return null
31
+ if (canvas) return canvas
32
+ canvas = document.createElement('canvas')
33
+ canvas.setAttribute('data-innertia-confetti', '')
34
+ Object.assign(canvas.style, {
35
+ position: 'fixed',
36
+ inset: '0',
37
+ width: '100%',
38
+ height: '100%',
39
+ pointerEvents: 'none',
40
+ zIndex: '99999',
41
+ })
42
+ document.body.appendChild(canvas)
43
+ ctx = canvas.getContext('2d')
44
+ resize()
45
+ resizeHandler = () => resize()
46
+ window.addEventListener('resize', resizeHandler)
47
+ return canvas
48
+ }
49
+
50
+ function resize() {
51
+ if (!canvas) return
52
+ const dpr = window.devicePixelRatio || 1
53
+ canvas.width = window.innerWidth * dpr
54
+ canvas.height = window.innerHeight * dpr
55
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
56
+ }
57
+
58
+ function destroyCanvas() {
59
+ if (!canvas) return
60
+ canvas.remove()
61
+ canvas = null
62
+ ctx = null
63
+ if (resizeHandler) window.removeEventListener('resize', resizeHandler)
64
+ resizeHandler = null
65
+ }
66
+
67
+ function spawnParticles({
68
+ origin = { x: 0.5, y: 0.5 },
69
+ count = 80,
70
+ spread = 70,
71
+ angle = 90, // 90 = arriba, 0 = derecha, 180 = izquierda
72
+ velocity = 18,
73
+ colors = DEFAULT_COLORS,
74
+ shapes = ['square', 'circle', 'rect'],
75
+ ticks = 200, // vida en frames
76
+ scalar = 1,
77
+ } = {}) {
78
+ ensureCanvas()
79
+ if (!ctx) return
80
+
81
+ const radSpread = (spread * Math.PI) / 180
82
+ const radAngle = (angle * Math.PI) / 180
83
+ const ox = origin.x * window.innerWidth
84
+ const oy = origin.y * window.innerHeight
85
+
86
+ for (let i = 0; i < count; i++) {
87
+ const a = radAngle - radSpread / 2 + Math.random() * radSpread
88
+ const v = velocity * (0.5 + Math.random() * 0.7)
89
+ particles.push({
90
+ x: ox,
91
+ y: oy,
92
+ vx: Math.cos(a) * v,
93
+ vy: -Math.sin(a) * v,
94
+ rot: Math.random() * Math.PI * 2,
95
+ vrot: (Math.random() - 0.5) * 0.3,
96
+ size: (5 + Math.random() * 6) * scalar,
97
+ color: colors[(Math.random() * colors.length) | 0],
98
+ shape: shapes[(Math.random() * shapes.length) | 0],
99
+ life: 0,
100
+ maxLife: ticks * (0.7 + Math.random() * 0.6),
101
+ wobble: Math.random() * 2 * Math.PI,
102
+ wobbleSpeed: 0.05 + Math.random() * 0.05,
103
+ })
104
+ }
105
+
106
+ if (!rafId) tick()
107
+ }
108
+
109
+ function tick() {
110
+ if (!ctx) { rafId = null; return }
111
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
112
+
113
+ for (let i = particles.length - 1; i >= 0; i--) {
114
+ const p = particles[i]
115
+ p.vx *= 1 - DRAG
116
+ p.vy = p.vy * (1 - DRAG) + GRAVITY
117
+ p.x += p.vx
118
+ p.y += p.vy
119
+ p.rot += p.vrot
120
+ p.wobble += p.wobbleSpeed
121
+ p.life++
122
+
123
+ if (p.life >= p.maxLife || p.y > window.innerHeight + 40) {
124
+ particles.splice(i, 1)
125
+ continue
126
+ }
127
+
128
+ const alpha = Math.max(0, 1 - p.life / p.maxLife)
129
+ ctx.save()
130
+ ctx.globalAlpha = alpha
131
+ ctx.translate(p.x + Math.cos(p.wobble) * 2, p.y)
132
+ ctx.rotate(p.rot)
133
+ ctx.fillStyle = p.color
134
+
135
+ if (p.shape === 'circle') {
136
+ ctx.beginPath()
137
+ ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2)
138
+ ctx.fill()
139
+ } else if (p.shape === 'rect') {
140
+ ctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2)
141
+ } else {
142
+ ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size)
143
+ }
144
+ ctx.restore()
145
+ }
146
+
147
+ if (particles.length) {
148
+ rafId = requestAnimationFrame(tick)
149
+ } else {
150
+ rafId = null
151
+ // Mantener el canvas montado — limpiarlo es opcional. Lo dejamos para no
152
+ // recrear elementos en bursts repetidos.
153
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
154
+ }
155
+ }
156
+
157
+ export function useConfetti() {
158
+ /** Explosión simple. */
159
+ const burst = (opts = {}) => spawnParticles({ count: 90, spread: 70, velocity: 22, ...opts })
160
+
161
+ /** Un "cañón" direccional (ej. desde una esquina). */
162
+ const cannon = (opts = {}) => spawnParticles({ count: 60, spread: 30, velocity: 25, ...opts })
163
+
164
+ /** Dos cañones laterales con colores propios. */
165
+ const schoolPride = (colors = DEFAULT_COLORS, opts = {}) => {
166
+ spawnParticles({ origin: { x: 0, y: 0.7 }, angle: 60, spread: 55, count: 50, colors, velocity: 25, ...opts })
167
+ spawnParticles({ origin: { x: 1, y: 0.7 }, angle: 120, spread: 55, count: 50, colors, velocity: 25, ...opts })
168
+ }
169
+
170
+ /** Fuegos artificiales repetidos durante `duration` ms. */
171
+ const fireworks = ({ duration = 3000, interval = 250, ...opts } = {}) => {
172
+ const end = Date.now() + duration
173
+ const iv = setInterval(() => {
174
+ if (Date.now() >= end) { clearInterval(iv); return }
175
+ spawnParticles({
176
+ origin: { x: 0.2 + Math.random() * 0.6, y: 0.3 + Math.random() * 0.3 },
177
+ count: 40,
178
+ spread: 360,
179
+ velocity: 18,
180
+ ...opts,
181
+ })
182
+ }, interval)
183
+ return () => clearInterval(iv)
184
+ }
185
+
186
+ /** Limpia todas las partículas activas y destruye el canvas. */
187
+ const stop = () => {
188
+ particles = []
189
+ if (rafId) { cancelAnimationFrame(rafId); rafId = null }
190
+ destroyCanvas()
191
+ }
192
+
193
+ return { burst, cannon, schoolPride, fireworks, stop }
194
+ }