@escalated-dev/escalated 0.4.0 → 0.6.0

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.
package/README.md CHANGED
@@ -18,6 +18,7 @@ Pick your framework:
18
18
  | **Laravel** | [escalated-dev/escalated-laravel](https://github.com/escalated-dev/escalated-laravel) | `composer require escalated-dev/escalated-laravel` |
19
19
  | **Rails** | [escalated-dev/escalated-rails](https://github.com/escalated-dev/escalated-rails) | `gem "escalated"` |
20
20
  | **Django** | [escalated-dev/escalated-django](https://github.com/escalated-dev/escalated-django) | `pip install escalated-django` |
21
+ | **AdonisJS** | [escalated-dev/escalated-adonis](https://github.com/escalated-dev/escalated-adonis) | `npm install @escalated-dev/escalated-adonis` |
21
22
 
22
23
  Each backend repo has full setup instructions — install command, migrations, config, and frontend integration.
23
24
 
@@ -112,9 +113,27 @@ import BaseLayout from '@/layouts/BaseLayout.vue'
112
113
  app.use(EscalatedPlugin, { layout: BaseLayout })
113
114
  ```
114
115
 
116
+ **AdonisJS** (Inertia + Vue 3):
117
+ ```js
118
+ import { EscalatedPlugin } from '@escalated-dev/escalated'
119
+ import AppLayout from '@/layouts/AppLayout.vue'
120
+
121
+ app.use(EscalatedPlugin, { layout: AppLayout })
122
+ ```
123
+
115
124
  ## What's in This Repo
116
125
 
117
- All the Vue 3 + Inertia.js components that power the Escalated UI. These are identical across Laravel, Rails, and Django — the backend framework renders them via Inertia.
126
+ All the Vue 3 + Inertia.js components that power the Escalated UI. These are identical across Laravel, Rails, Django, and AdonisJS — the backend framework renders them via Inertia.
127
+
128
+ ## 📸 Screenshots
129
+
130
+ <p align="center">
131
+ <img src="docs/assets/escalated_admin_1.png" alt="Escalated Admin Screenshot 1" width="800" />
132
+ </p>
133
+
134
+ <p align="center">
135
+ <img src="docs/assets/escalated_admin_2.png" alt="Escalated Admin Screenshot 2" width="800" />
136
+ </p>
118
137
 
119
138
  ### Pages
120
139
 
@@ -157,6 +176,20 @@ Reusable building blocks used across the pages above.
157
176
  | `AttachmentList` | File attachment display with download links |
158
177
  | `StatsCard` | Metric card with label, value, and trend |
159
178
  | `EscalatedLayout` | Top-level layout with navigation (supports host layout injection) |
179
+ | `BulkActionBar` | Toolbar for batch operations on selected tickets |
180
+ | `QuickFilters` | One-click filter chips (My Tickets, Unassigned, Urgent, SLA Breaching) |
181
+ | `MacroDropdown` | Dropdown to apply multi-step macros to a ticket |
182
+ | `FollowButton` | Toggle button to follow/unfollow a ticket |
183
+ | `SatisfactionRating` | 1-5 star CSAT rating input with optional comment |
184
+ | `KeyboardShortcutHelp` | Modal overlay showing all available keyboard shortcuts |
185
+ | `PinnedNotes` | Display pinned internal notes at the top of the thread |
186
+ | `PresenceIndicator` | Real-time indicator showing who is viewing a ticket |
187
+
188
+ ### Composables
189
+
190
+ | Composable | Description |
191
+ |------------|-------------|
192
+ | `useKeyboardShortcuts` | Registers and manages keyboard shortcuts for ticket actions |
160
193
 
161
194
  ### Plugin
162
195
 
@@ -185,6 +218,17 @@ import CustomerIndex from '@escalated-dev/escalated/pages/Customer/Index.vue'
185
218
 
186
219
  Peer dependencies: `vue` ^3.3.0, `@inertiajs/vue3` ^1.0.0 || ^2.0.0
187
220
 
221
+ ## Ecosystem
222
+
223
+ This is the shared frontend for the Escalated support ticket system. Backend packages available for every major framework:
224
+
225
+ - **[Escalated for Laravel](https://github.com/escalated-dev/escalated-laravel)** — Laravel Composer package
226
+ - **[Escalated for Rails](https://github.com/escalated-dev/escalated-rails)** — Ruby on Rails engine
227
+ - **[Escalated for Django](https://github.com/escalated-dev/escalated-django)** — Django reusable app
228
+ - **[Escalated for AdonisJS](https://github.com/escalated-dev/escalated-adonis)** — AdonisJS v6 package
229
+ - **[Escalated for Filament](https://github.com/escalated-dev/escalated-filament)** — Filament v3 admin panel plugin
230
+ - **[Shared Frontend](https://github.com/escalated-dev/escalated)** — Vue 3 + Inertia.js UI components (you are here)
231
+
188
232
  ## License
189
233
 
190
234
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escalated-dev/escalated",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Vue 3 + Inertia.js UI components for Escalated — the embeddable support ticket system",
5
5
  "author": "Escalated Dev <hello@escalated.dev>",
6
6
  "license": "MIT",
@@ -21,17 +21,26 @@
21
21
  "exports": {
22
22
  "./components/*": "./src/components/*",
23
23
  "./pages/*": "./src/pages/*",
24
+ "./locales": "./src/locales/index.js",
25
+ "./locales/*": "./src/locales/*",
24
26
  ".": "./src/index.js"
25
27
  },
26
28
  "files": [
27
29
  "src"
28
30
  ],
29
31
  "peerDependencies": {
30
- "vue": "^3.3.0",
31
- "@inertiajs/vue3": "^1.0.0 || ^2.0.0"
32
+ "@inertiajs/vue3": "^1.0.0 || ^2.0.0",
33
+ "vue": "^3.3.0"
34
+ },
35
+ "scripts": {
36
+ "test": "vitest run"
32
37
  },
33
38
  "devDependencies": {
34
- "vue": "^3.5.0",
35
- "@inertiajs/vue3": "^2.0.0"
39
+ "@inertiajs/vue3": "^2.0.0",
40
+ "@vitejs/plugin-vue": "^5.0.0",
41
+ "@vue/test-utils": "^2.4.0",
42
+ "happy-dom": "^15.0.0",
43
+ "vitest": "^2.0.0",
44
+ "vue": "^3.5.0"
36
45
  }
37
46
  }
@@ -1,66 +1,66 @@
1
- <script setup>
2
- import { computed, inject } from 'vue';
3
-
4
- defineProps({
5
- activities: { type: Array, required: true },
6
- });
7
-
8
- const dark = inject('esc-dark', computed(() => false));
9
-
10
- const typeLabels = {
11
- status_changed: 'changed status',
12
- assigned: 'assigned ticket',
13
- unassigned: 'unassigned ticket',
14
- priority_changed: 'changed priority',
15
- tag_added: 'added tag',
16
- tag_removed: 'removed tag',
17
- escalated: 'escalated ticket',
18
- sla_breached: 'SLA breached',
19
- replied: 'replied',
20
- note_added: 'added note',
21
- department_changed: 'changed department',
22
- reopened: 'reopened ticket',
23
- resolved: 'resolved ticket',
24
- closed: 'closed ticket',
25
- };
26
-
27
- function formatDate(date) {
28
- return new Date(date).toLocaleString();
29
- }
30
-
31
- function describeActivity(activity) {
32
- const label = typeLabels[activity.type] || activity.type;
33
- const who = activity.causer?.name || 'System';
34
- const props = activity.properties || {};
35
-
36
- if (activity.type === 'status_changed' && props.to) {
37
- return `${who} ${label} to ${props.to}`;
38
- }
39
- if (activity.type === 'assigned' && props.agent_name) {
40
- return `${who} assigned to ${props.agent_name}`;
41
- }
42
- if (activity.type === 'priority_changed' && props.to) {
43
- return `${who} ${label} to ${props.to}`;
44
- }
45
- if ((activity.type === 'tag_added' || activity.type === 'tag_removed') && props.tag) {
46
- return `${who} ${label} "${props.tag}"`;
47
- }
48
- if (activity.type === 'department_changed' && props.department) {
49
- return `${who} ${label} to ${props.department}`;
50
- }
51
- return `${who} ${label}`;
52
- }
53
- </script>
54
-
55
- <template>
56
- <div class="space-y-3">
57
- <div v-for="activity in activities" :key="activity.id" class="flex gap-3 text-sm">
58
- <div :class="['mt-1 h-2 w-2 flex-shrink-0 rounded-full', dark ? 'bg-neutral-600' : 'bg-gray-400']"></div>
59
- <div class="flex-1">
60
- <p :class="dark ? 'text-neutral-300' : 'text-gray-700'">{{ describeActivity(activity) }}</p>
61
- <p :class="['text-xs', dark ? 'text-neutral-600' : 'text-gray-400']">{{ formatDate(activity.created_at) }}</p>
62
- </div>
63
- </div>
64
- <div v-if="!activities?.length" :class="['py-4 text-center text-sm', dark ? 'text-neutral-600' : 'text-gray-500']">No activity yet.</div>
65
- </div>
66
- </template>
1
+ <script setup>
2
+ import { computed, inject } from 'vue';
3
+
4
+ defineProps({
5
+ activities: { type: Array, required: true },
6
+ });
7
+
8
+ const dark = inject('esc-dark', computed(() => false));
9
+
10
+ const typeLabels = {
11
+ status_changed: 'changed status',
12
+ assigned: 'assigned ticket',
13
+ unassigned: 'unassigned ticket',
14
+ priority_changed: 'changed priority',
15
+ tag_added: 'added tag',
16
+ tag_removed: 'removed tag',
17
+ escalated: 'escalated ticket',
18
+ sla_breached: 'SLA breached',
19
+ replied: 'replied',
20
+ note_added: 'added note',
21
+ department_changed: 'changed department',
22
+ reopened: 'reopened ticket',
23
+ resolved: 'resolved ticket',
24
+ closed: 'closed ticket',
25
+ };
26
+
27
+ function formatDate(date) {
28
+ return new Date(date).toLocaleString();
29
+ }
30
+
31
+ function describeActivity(activity) {
32
+ const label = typeLabels[activity.type] || activity.type;
33
+ const who = activity.causer?.name || 'System';
34
+ const props = activity.properties || {};
35
+
36
+ if (activity.type === 'status_changed' && props.to) {
37
+ return `${who} ${label} to ${props.to}`;
38
+ }
39
+ if (activity.type === 'assigned' && props.agent_name) {
40
+ return `${who} assigned to ${props.agent_name}`;
41
+ }
42
+ if (activity.type === 'priority_changed' && props.to) {
43
+ return `${who} ${label} to ${props.to}`;
44
+ }
45
+ if ((activity.type === 'tag_added' || activity.type === 'tag_removed') && props.tag) {
46
+ return `${who} ${label} "${props.tag}"`;
47
+ }
48
+ if (activity.type === 'department_changed' && props.department) {
49
+ return `${who} ${label} to ${props.department}`;
50
+ }
51
+ return `${who} ${label}`;
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <div class="space-y-3">
57
+ <div v-for="activity in activities" :key="activity.id" class="flex gap-3 text-sm">
58
+ <div :class="['mt-1 h-2 w-2 flex-shrink-0 rounded-full', dark ? 'bg-neutral-600' : 'bg-gray-400']"></div>
59
+ <div class="flex-1">
60
+ <p :class="dark ? 'text-neutral-300' : 'text-gray-700'">{{ describeActivity(activity) }}</p>
61
+ <p :class="['text-xs', dark ? 'text-neutral-600' : 'text-gray-400']">{{ formatDate(activity.created_at) }}</p>
62
+ </div>
63
+ </div>
64
+ <div v-if="!activities?.length" :class="['py-4 text-center text-sm', dark ? 'text-neutral-600' : 'text-gray-500']">No activity yet.</div>
65
+ </div>
66
+ </template>
@@ -1,23 +1,23 @@
1
- <script setup>
2
- import { computed, inject } from 'vue';
3
-
4
- defineProps({
5
- agents: { type: Array, required: true },
6
- modelValue: { type: [Number, String], default: null },
7
- });
8
-
9
- const emit = defineEmits(['update:modelValue']);
10
- const dark = inject('esc-dark', computed(() => false));
11
- </script>
12
-
13
- <template>
14
- <div>
15
- <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Assigned To</label>
16
- <select :value="modelValue" @change="emit('update:modelValue', $event.target.value || null)"
17
- :class="['w-full rounded-md border px-2 py-1.5 text-sm focus:outline-none',
18
- dark ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']">
19
- <option value="">Unassigned</option>
20
- <option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option>
21
- </select>
22
- </div>
23
- </template>
1
+ <script setup>
2
+ import { computed, inject } from 'vue';
3
+
4
+ defineProps({
5
+ agents: { type: Array, required: true },
6
+ modelValue: { type: [Number, String], default: null },
7
+ });
8
+
9
+ const emit = defineEmits(['update:modelValue']);
10
+ const dark = inject('esc-dark', computed(() => false));
11
+ </script>
12
+
13
+ <template>
14
+ <div>
15
+ <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Assigned To</label>
16
+ <select :value="modelValue" @change="emit('update:modelValue', $event.target.value || null)"
17
+ :class="['w-full rounded-md border px-2 py-1.5 text-sm focus:outline-none',
18
+ dark ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']">
19
+ <option value="">Unassigned</option>
20
+ <option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option>
21
+ </select>
22
+ </div>
23
+ </template>
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  import { computed, inject, provide } from 'vue';
3
3
  import { usePage, Link } from '@inertiajs/vue3';
4
+ import { usePluginExtensions } from '../composables/usePluginExtensions';
4
5
 
5
6
  const props = defineProps({
6
7
  title: { type: String, default: 'Support' },
@@ -23,17 +24,36 @@ const isDark = computed(() => isAdminSection.value || isAgentSection.value);
23
24
 
24
25
  provide('esc-dark', isDark);
25
26
 
26
- const adminLinks = computed(() => [
27
- { href: `${prefix.value}/admin/reports`, label: 'Reports', icon: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z' },
28
- { href: `${prefix.value}/admin/tickets`, label: 'Tickets', icon: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z' },
29
- { href: `${prefix.value}/admin/departments`, label: 'Departments', icon: 'M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z' },
30
- { href: `${prefix.value}/admin/sla-policies`, label: 'SLA Policies', icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z' },
31
- { href: `${prefix.value}/admin/escalation-rules`, label: 'Escalation Rules', icon: 'M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12' },
32
- { href: `${prefix.value}/admin/tags`, label: 'Tags', icon: 'M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z M6 6h.008v.008H6V6z' },
33
- { href: `${prefix.value}/admin/canned-responses`, label: 'Canned Responses', icon: 'M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z' },
34
- { href: `${prefix.value}/admin/macros`, label: 'Macros', icon: 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' },
35
- { href: `${prefix.value}/admin/settings`, label: 'Settings', icon: 'M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.781.93l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894zM15 12a3 3 0 11-6 0 3 3 0 016 0z' },
36
- ]);
27
+ const { menuItems: pluginMenuItems } = usePluginExtensions();
28
+
29
+ const adminLinks = computed(() => {
30
+ const p = prefix.value;
31
+ const coreLinks = [
32
+ { href: `${p}/admin/reports`, label: 'Reports', icon: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z', position: 10 },
33
+ { href: `${p}/admin/tickets`, label: 'Tickets', icon: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z', position: 20 },
34
+ { href: `${p}/admin/departments`, label: 'Departments', icon: 'M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z', position: 30 },
35
+ { href: `${p}/admin/sla-policies`, label: 'SLA Policies', icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z', position: 40 },
36
+ { href: `${p}/admin/escalation-rules`, label: 'Escalation Rules', icon: 'M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12', position: 50 },
37
+ { href: `${p}/admin/tags`, label: 'Tags', icon: 'M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z M6 6h.008v.008H6V6z', position: 60 },
38
+ { href: `${p}/admin/canned-responses`, label: 'Canned Responses', icon: 'M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z', position: 70 },
39
+ { href: `${p}/admin/macros`, label: 'Macros', icon: 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z', position: 80 },
40
+ { href: `${p}/admin/api-tokens`, label: 'API Tokens', icon: 'M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z', position: 90 },
41
+ { href: `${p}/admin/plugins`, label: 'Plugins', icon: 'M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z', position: 95 },
42
+ { href: `${p}/admin/settings`, label: 'Settings', icon: 'M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.781.93l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894zM15 12a3 3 0 11-6 0 3 3 0 016 0z', position: 100 },
43
+ ];
44
+
45
+ // Merge core admin links with plugin-provided menu items
46
+ const pluginLinks = pluginMenuItems.value.map(item => ({
47
+ href: item.route,
48
+ label: item.label,
49
+ icon: item.icon,
50
+ position: item.position || 100,
51
+ badge: item.badge || null,
52
+ submenu: item.submenu || [],
53
+ }));
54
+
55
+ return [...coreLinks, ...pluginLinks].sort((a, b) => (a.position || 100) - (b.position || 100));
56
+ });
37
57
 
38
58
  const agentLinks = computed(() => [
39
59
  { href: `${prefix.value}/agent`, label: 'Dashboard', icon: 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25' },
@@ -80,6 +100,10 @@ function isActive(href) {
80
100
  <path stroke-linecap="round" stroke-linejoin="round" :d="link.icon" />
81
101
  </svg>
82
102
  {{ link.label }}
103
+ <span v-if="link.badge"
104
+ class="ml-auto inline-flex h-5 min-w-[20px] items-center justify-center rounded-full bg-cyan-500/20 px-1.5 text-[10px] font-semibold text-cyan-400">
105
+ {{ link.badge }}
106
+ </span>
83
107
  </Link>
84
108
  </nav>
85
109
 
@@ -1,41 +1,41 @@
1
- <script setup>
2
- import { ref, computed, inject } from 'vue';
3
-
4
- const emit = defineEmits(['files']);
5
- const dark = inject('esc-dark', computed(() => false));
6
-
7
- const dragging = ref(false);
8
- const fileInput = ref(null);
9
-
10
- function onDrop(e) {
11
- dragging.value = false;
12
- if (e.dataTransfer?.files?.length) {
13
- emit('files', Array.from(e.dataTransfer.files));
14
- }
15
- }
16
-
17
- function onFileSelect(e) {
18
- if (e.target.files?.length) {
19
- emit('files', Array.from(e.target.files));
20
- e.target.value = '';
21
- }
22
- }
23
-
24
- function browse() {
25
- fileInput.value?.click();
26
- }
27
- </script>
28
-
29
- <template>
30
- <div @dragover.prevent="dragging = true" @dragleave="dragging = false" @drop.prevent="onDrop"
31
- :class="['cursor-pointer rounded-lg border-2 border-dashed px-4 py-3 text-center text-xs transition-colors',
32
- dark
33
- ? (dragging ? 'border-white/20 bg-white/[0.04]' : 'border-white/[0.08] hover:border-white/[0.12]')
34
- : (dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400')]"
35
- @click="browse">
36
- <p :class="dark ? 'text-neutral-500' : 'text-gray-500'">
37
- Drop files here or <span :class="['font-medium', dark ? 'text-white' : 'text-blue-600']">browse</span>
38
- </p>
39
- <input ref="fileInput" type="file" multiple class="hidden" @change="onFileSelect" />
40
- </div>
41
- </template>
1
+ <script setup>
2
+ import { ref, computed, inject } from 'vue';
3
+
4
+ const emit = defineEmits(['files']);
5
+ const dark = inject('esc-dark', computed(() => false));
6
+
7
+ const dragging = ref(false);
8
+ const fileInput = ref(null);
9
+
10
+ function onDrop(e) {
11
+ dragging.value = false;
12
+ if (e.dataTransfer?.files?.length) {
13
+ emit('files', Array.from(e.dataTransfer.files));
14
+ }
15
+ }
16
+
17
+ function onFileSelect(e) {
18
+ if (e.target.files?.length) {
19
+ emit('files', Array.from(e.target.files));
20
+ e.target.value = '';
21
+ }
22
+ }
23
+
24
+ function browse() {
25
+ fileInput.value?.click();
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div @dragover.prevent="dragging = true" @dragleave="dragging = false" @drop.prevent="onDrop"
31
+ :class="['cursor-pointer rounded-lg border-2 border-dashed px-4 py-3 text-center text-xs transition-colors',
32
+ dark
33
+ ? (dragging ? 'border-white/20 bg-white/[0.04]' : 'border-white/[0.08] hover:border-white/[0.12]')
34
+ : (dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400')]"
35
+ @click="browse">
36
+ <p :class="dark ? 'text-neutral-500' : 'text-gray-500'">
37
+ Drop files here or <span :class="['font-medium', dark ? 'text-white' : 'text-blue-600']">browse</span>
38
+ </p>
39
+ <input ref="fileInput" type="file" multiple class="hidden" @change="onFileSelect" />
40
+ </div>
41
+ </template>
@@ -1,6 +1,8 @@
1
1
  <script setup>
2
2
  import { ref, computed, inject } from 'vue';
3
3
  import { router } from '@inertiajs/vue3';
4
+ import { sanitizeHtml } from '../utils/sanitizeHtml';
5
+ import { useI18n } from '../composables/useI18n';
4
6
 
5
7
  const props = defineProps({
6
8
  notes: { type: Array, default: () => [] },
@@ -9,6 +11,7 @@ const props = defineProps({
9
11
  });
10
12
 
11
13
  const escDark = inject('esc-dark', computed(() => false));
14
+ const { t } = useI18n();
12
15
  const processingId = ref(null);
13
16
 
14
17
  function unpinNote(note) {
@@ -49,7 +52,7 @@ function formatDate(dateStr) {
49
52
  </svg>
50
53
  <span :class="['text-xs font-semibold uppercase tracking-wider',
51
54
  escDark ? 'text-amber-400' : 'text-amber-700']">
52
- Pinned Notes
55
+ {{ t('pinned_notes.title') }}
53
56
  </span>
54
57
  </div>
55
58
 
@@ -61,7 +64,7 @@ function formatDate(dateStr) {
61
64
  ? 'border-amber-500/10 bg-amber-500/[0.04]'
62
65
  : 'border-amber-200 bg-white']">
63
66
  <!-- Note body -->
64
- <div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-neutral-200' : 'text-gray-800']" v-html="note.body"></div>
67
+ <div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-neutral-200' : 'text-gray-800']" v-html="sanitizeHtml(note.body)"></div>
65
68
 
66
69
  <!-- Meta row -->
67
70
  <div class="mt-2 flex items-center justify-between">
@@ -78,7 +81,7 @@ function formatDate(dateStr) {
78
81
  ? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300'
79
82
  : 'text-amber-600 hover:bg-amber-100 hover:text-amber-700',
80
83
  processingId === note.id && 'opacity-50 cursor-not-allowed']">
81
- Unpin
84
+ {{ t('pinned_notes.unpin') }}
82
85
  </button>
83
86
  </div>
84
87
  </div>
@@ -0,0 +1,62 @@
1
+ <script setup>
2
+ import { defineAsyncComponent, h } from 'vue';
3
+
4
+ const props = defineProps({
5
+ slot: {
6
+ type: String,
7
+ required: true,
8
+ },
9
+ components: {
10
+ type: Array,
11
+ default: () => [],
12
+ },
13
+ });
14
+
15
+ // Error fallback component rendered when a plugin component fails to load
16
+ const ErrorFallback = {
17
+ props: ['error', 'plugin', 'component'],
18
+ render() {
19
+ return h('div', {
20
+ class: 'rounded-lg border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-xs text-rose-400',
21
+ }, `Plugin "${this.plugin}" failed to load component "${this.component}"`);
22
+ },
23
+ };
24
+
25
+ // Loading placeholder component
26
+ const LoadingFallback = {
27
+ render() {
28
+ return h('div', {
29
+ class: 'animate-pulse rounded-lg bg-white/[0.03] px-3 py-2',
30
+ }, [
31
+ h('div', { class: 'h-4 w-24 rounded bg-white/[0.06]' }),
32
+ ]);
33
+ },
34
+ };
35
+
36
+ // Load plugin components dynamically from host app's plugins directory
37
+ const loadPluginComponent = (plugin, component) => {
38
+ return defineAsyncComponent({
39
+ loader: () => import(`../../../../plugins/escalated/${plugin}/resources/js/Components/${component}.vue`),
40
+ loadingComponent: LoadingFallback,
41
+ errorComponent: {
42
+ props: ['error'],
43
+ render() {
44
+ return h(ErrorFallback, { error: this.error, plugin, component });
45
+ },
46
+ },
47
+ delay: 100,
48
+ timeout: 10000,
49
+ });
50
+ };
51
+ </script>
52
+
53
+ <template>
54
+ <template v-if="components?.length > 0">
55
+ <component
56
+ v-for="(pluginComp, index) in components"
57
+ :key="`${slot}-${pluginComp.plugin}-${pluginComp.component}-${index}`"
58
+ :is="loadPluginComponent(pluginComp.plugin, pluginComp.component)"
59
+ v-bind="pluginComp.data || {}"
60
+ />
61
+ </template>
62
+ </template>