@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 +45 -1
- package/package.json +14 -5
- package/src/components/ActivityTimeline.vue +66 -66
- package/src/components/AssigneeSelect.vue +23 -23
- package/src/components/EscalatedLayout.vue +35 -11
- package/src/components/FileDropzone.vue +41 -41
- package/src/components/PinnedNotes.vue +6 -3
- package/src/components/PluginSlot.vue +62 -0
- package/src/components/PriorityBadge.vue +39 -39
- package/src/components/ReplyComposer.vue +159 -159
- package/src/components/ReplyThread.vue +9 -6
- package/src/components/SlaTimer.vue +49 -49
- package/src/components/StatusBadge.vue +46 -46
- package/src/components/TagSelect.vue +53 -53
- package/src/composables/useI18n.js +80 -0
- package/src/composables/usePluginExtensions.js +67 -0
- package/src/defineEscalatedPlugin.js +50 -0
- package/src/index.js +5 -0
- package/src/locales/de.json +442 -0
- package/src/locales/en.json +442 -0
- package/src/locales/es.json +442 -0
- package/src/locales/fr.json +442 -0
- package/src/locales/index.js +6 -0
- package/src/pages/Admin/ApiTokens/Index.vue +217 -0
- package/src/pages/Admin/Plugins/Index.vue +197 -0
- package/src/pages/Admin/Plugins.vue +129 -0
- package/src/pages/Admin/Tickets/Show.vue +12 -1
- package/src/pages/Agent/Dashboard.vue +31 -1
- package/src/pages/Agent/TicketShow.vue +12 -1
- package/src/utils/sanitizeHtml.js +64 -0
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
|
|
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.
|
|
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
|
-
"
|
|
31
|
-
"
|
|
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
|
-
"
|
|
35
|
-
"@
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|