From ba96d839afca86f9ab63f566abc8a6a607d101ab Mon Sep 17 00:00:00 2001
From: Frank Rousseau
Date: Mon, 29 Dec 2025 16:35:36 +0100
Subject: [PATCH 1/6] [plugins] Allow to add plugin pages to the menus
---
src/components/pages/Plugin.vue | 80 +++++++++++++++++
src/components/sides/Sidebar.vue | 20 ++++-
src/components/tops/Topbar.vue | 29 ++++--
src/components/tops/TopbarEpisodeList.vue | 8 +-
src/components/tops/TopbarProductionList.vue | 4 +-
src/components/tops/TopbarSectionList.vue | 32 +++++--
src/lib/path.js | 93 +++++++++++++-------
src/lib/string.js | 4 +
src/router/routes.js | 19 ++++
src/store/modules/user.js | 20 ++++-
src/store/mutation-types.js | 4 +
11 files changed, 261 insertions(+), 52 deletions(-)
create mode 100644 src/components/pages/Plugin.vue
diff --git a/src/components/pages/Plugin.vue b/src/components/pages/Plugin.vue
new file mode 100644
index 0000000000..8d901fbfd0
--- /dev/null
+++ b/src/components/pages/Plugin.vue
@@ -0,0 +1,80 @@
+
+
+
Plugin
+ {{ plugin?.name }}
+
+
+
+
+
+
+
+
diff --git a/src/components/sides/Sidebar.vue b/src/components/sides/Sidebar.vue
index a08213e127..d5299c65e0 100644
--- a/src/components/sides/Sidebar.vue
+++ b/src/components/sides/Sidebar.vue
@@ -124,6 +124,19 @@
+
{{ $t('main.admin') }}
@@ -236,6 +249,7 @@ import {
GlobeIcon,
Rows4Icon
} from 'lucide-vue-next'
+import Icon from '@/components/widgets/Icon.vue'
import KitsuIcon from '@/components/widgets/KitsuIcon.vue'
@@ -248,7 +262,8 @@ export default {
EggIcon,
GlobeIcon,
KitsuIcon,
- Rows4Icon
+ Rows4Icon,
+ Icon
},
data() {
@@ -272,7 +287,8 @@ export default {
'isCurrentUserVendor',
'isSidebarHidden',
'mainConfig',
- 'organisation'
+ 'organisation',
+ 'studioPlugins'
]),
isLongLocale() {
diff --git a/src/components/tops/Topbar.vue b/src/components/tops/Topbar.vue
index 4b7225b2e0..1041aecf2a 100644
--- a/src/components/tops/Topbar.vue
+++ b/src/components/tops/Topbar.vue
@@ -302,8 +302,9 @@ export default {
'notifications',
'openProductions',
'organisation',
- 'productionMap',
'productionEditTaskTypes',
+ 'productionMap',
+ 'projectPlugins',
'user'
]),
@@ -369,10 +370,12 @@ export default {
},
isEpisodeContext() {
+ const isPlugin = this.$route.params.plugin_id !== undefined
return (
this.isTVShow &&
this.hasEpisodeId &&
- !['episodes', 'episode-stats'].includes(this.currentSectionOption) &&
+ (!['episodes', 'episode-stats'].includes(this.currentSectionOption) ||
+ isPlugin) &&
// Do not display combobox if there is no episode
this.episodes.length > 0
)
@@ -484,6 +487,16 @@ export default {
}
options.push({ label: this.$t('people.team'), value: 'team' })
+ this.projectPlugins.forEach(plugin => {
+ options.push({
+ label: plugin.name,
+ value: plugin.plugin_id,
+ icon: plugin.icon,
+ plugin_id: plugin.plugin_id,
+ type: 'plugin'
+ })
+ })
+
if (this.isCurrentUserManager) {
options = options.concat([
{ label: 'separator', value: 'separator' },
@@ -551,6 +564,9 @@ export default {
]),
getCurrentSectionFromRoute() {
+ if (this.$route.name.includes('production-plugin')) {
+ return this.$route.params.plugin_id
+ }
if (this.$route.name === 'person') {
return 'person'
}
@@ -703,11 +719,13 @@ export default {
updateCombosFromRoute() {
const productionId = this.$route.params.production_id
+ const pluginId = this.$route.params.plugin_id
const section = this.getCurrentSectionFromRoute()
let episodeId = this.$route.params.episode_id
this.silent = true
this.currentProductionId = productionId
this.currentProjectSection = section
+ this.currentPluginId = pluginId
const isAssetSection = this.assetSections.includes(section)
const isEditSection = this.editSections.includes(section)
const isBreakdownSection = this.breakdownSections.includes(section)
@@ -719,13 +737,13 @@ export default {
) {
episodeId = this.episodes[0].id
this.currentEpisodeId = episodeId
- this.pushContextRoute(section)
+ this.pushContextRoute(section, pluginId)
} else {
this.currentEpisodeId = episodeId
}
},
- pushContextRoute(section) {
+ pushContextRoute(section, pluginId = null) {
const isAssetSection = this.assetSections.includes(section)
const production = this.productionMap.get(this.currentProductionId)
const isTVShow = production?.production_type === 'tvshow'
@@ -742,7 +760,8 @@ export default {
let route = {
name: section,
params: {
- production_id: this.currentProductionId
+ production_id: this.currentProductionId,
+ plugin_id: pluginId
}
}
route = this.episodifyRoute(route, section, episodeId, isTVShow)
diff --git a/src/components/tops/TopbarEpisodeList.vue b/src/components/tops/TopbarEpisodeList.vue
index ce98b4ac46..d5c4332be2 100644
--- a/src/components/tops/TopbarEpisodeList.vue
+++ b/src/components/tops/TopbarEpisodeList.vue
@@ -107,8 +107,14 @@ export default {
getEpisodePath() {
const currentProduction = this.currentProduction
const section = this.section
+ const pluginId = this.$route.params.plugin_id
return episodeId => {
- const path = getProductionPath(currentProduction, section, episodeId)
+ const path = getProductionPath(
+ currentProduction,
+ section,
+ episodeId,
+ pluginId
+ )
return path
}
}
diff --git a/src/components/tops/TopbarProductionList.vue b/src/components/tops/TopbarProductionList.vue
index 3e4ae979b9..17f3b16213 100644
--- a/src/components/tops/TopbarProductionList.vue
+++ b/src/components/tops/TopbarProductionList.vue
@@ -110,10 +110,12 @@ export default {
},
getProductionPath(production) {
+ const pluginId = this.$route.params.plugin_id
return getProductionPath(
production,
this.section,
- this.episodeId || 'all'
+ this.episodeId || 'all',
+ pluginId
)
}
}
diff --git a/src/components/tops/TopbarSectionList.vue b/src/components/tops/TopbarSectionList.vue
index e078ed7816..9854d2ce17 100644
--- a/src/components/tops/TopbarSectionList.vue
+++ b/src/components/tops/TopbarSectionList.vue
@@ -11,10 +11,15 @@
class="selected-section-line flexrow-item flexrow"
v-if="currentSection"
>
+
+
+ {{ section.label }}
+
+
{
+const SECTION_NAME_MAP = {
+ assetTypes: 'production-asset-types',
+ newsFeed: 'news-feed',
+ plugin: 'production-plugin'
+}
+
+const TVSHOW_NON_EPISODIC_SECTIONS = [
+ 'news-feed',
+ 'schedule',
+ 'production-settings',
+ 'quota',
+ 'budget',
+ 'team',
+ 'episodes',
+ 'episode-stats',
+ 'concepts',
+ 'brief'
+]
+
+const SECTIONS_WITH_SEARCH = [
+ 'assets',
+ 'shots',
+ 'edits',
+ 'sequences',
+ 'episodes',
+ 'breakdown'
+]
+
+/* Enforce section depending on the production type */
+const _getAdjustedRouteName = (productionType, section, pluginId = null) => {
+ if (pluginId) {
+ section = 'production-plugin'
+ }
+ if (productionType === 'shots' && section === 'assets') {
+ return 'shots'
+ }
+ if (productionType === 'assets' && ['shots', 'sequences'].includes(section)) {
+ return 'assets'
+ }
+ return section
+}
+
+const getProductionRoute = (name, productionId, pluginId = null) => {
return {
name,
params: {
- production_id: productionId
+ production_id: productionId,
+ plugin_id: pluginId
}
}
}
@@ -141,44 +184,26 @@ const getProductionRoute = (name, productionId) => {
export const getProductionPath = (
production,
section = production.homepage || 'assets',
- episodeId
+ episodeId,
+ pluginId
) => {
- if (section === 'assetTypes') section = 'production-asset-types'
- if (section === 'newsFeed') section = 'news-feed'
- let route = getProductionRoute(section, production.id)
+ const normalizedSection = SECTION_NAME_MAP[section] || section
+ const routeName = _getAdjustedRouteName(
+ production.production_type,
+ normalizedSection,
+ pluginId
+ )
+ let route = getProductionRoute(routeName, production.id, pluginId)
- if (production.production_type === 'shots' && route.name === 'assets') {
- route.name = 'shots'
- } else if (
- production.production_type === 'assets' &&
- ['shots', 'sequences'].includes(route.name)
- ) {
- route.name = 'assets'
- }
-
- if (
+ const shouldEpisodify =
production.production_type === 'tvshow' &&
- ![
- 'news-feed',
- 'schedule',
- 'production-settings',
- 'quota',
- 'budget',
- 'team',
- 'episodes',
- 'episode-stats',
- 'concepts',
- 'brief'
- ].includes(section)
- ) {
+ !TVSHOW_NON_EPISODIC_SECTIONS.includes(normalizedSection)
+
+ if (shouldEpisodify) {
route = episodifyRoute(route, episodeId || 'all')
}
- if (
- ['assets', 'shots', 'edits', 'sequences', 'episodes', 'breakdown'].includes(
- section
- )
- ) {
+ if (SECTIONS_WITH_SEARCH.includes(normalizedSection)) {
route.query = { search: '' }
}
diff --git a/src/lib/string.js b/src/lib/string.js
index d90384e268..5cfcdb7b34 100644
--- a/src/lib/string.js
+++ b/src/lib/string.js
@@ -46,6 +46,10 @@ export default {
return str.charAt(0).toUpperCase() + str.slice(1)
},
+ snakeToCamel(str) {
+ return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
+ },
+
filenameWithoutExtension(filename) {
return filename.replace(/\.[^/.]+$/, '')
},
diff --git a/src/router/routes.js b/src/router/routes.js
index 97b7831f89..d17d41c013 100644
--- a/src/router/routes.js
+++ b/src/router/routes.js
@@ -51,6 +51,7 @@ const NotFound = () => import('@/components/pages/NotFound.vue')
const People = () => import('@/components/pages/People.vue')
const Person = () => import('@/components/pages/Person.vue')
const Playlist = () => import('@/components/pages/Playlist.vue')
+const Plugin = () => import('@/components/pages/Plugin.vue')
const ProductionAssetTypes = () =>
import('@/components/pages/ProductionAssetTypes.vue')
const ProductionQuota = () => import('@/components/pages/ProductionQuota.vue')
@@ -396,6 +397,12 @@ export const routes = [
]
},
+ {
+ path: '/plugins/:plugin_id',
+ component: Plugin,
+ name: 'plugin'
+ },
+
{
path: 'settings',
component: Settings,
@@ -490,6 +497,18 @@ export const routes = [
name: 'brief'
},
+ {
+ path: 'productions/:production_id/plugins/:plugin_id',
+ component: Plugin,
+ name: 'production-plugin'
+ },
+
+ {
+ path: 'productions/:production_id/episodes/:episode_id/plugins/:plugin_id',
+ component: Plugin,
+ name: 'episode-production-plugin'
+ },
+
{
path: 'productions/:production_id/quota',
component: ProductionQuota,
diff --git a/src/store/modules/user.js b/src/store/modules/user.js
index 10e390ec79..6f45cfe621 100644
--- a/src/store/modules/user.js
+++ b/src/store/modules/user.js
@@ -71,6 +71,7 @@ import {
LOAD_CUSTOM_ACTIONS_END,
LOAD_STATUS_AUTOMATIONS_END,
LOAD_ASSET_TYPES_END,
+ LOAD_PLUGINS_END,
SET_NOTIFICATION_COUNT,
LOAD_OPEN_PRODUCTIONS_END,
RESET_ALL,
@@ -119,7 +120,9 @@ const initialState = {
todoListScrollPosition: 0,
timeSpentMap: {},
- timeSpentTotal: 0
+ timeSpentTotal: 0,
+
+ plugins: []
}
const state = {
@@ -158,7 +161,13 @@ const getters = {
todoListScrollPosition: state => state.todoListScrollPosition,
timeSpentMap: state => state.timeSpentMap,
- timeSpentTotal: state => state.timeSpentTotal
+ timeSpentTotal: state => state.timeSpentTotal,
+
+ plugins: state => state.plugins,
+ studioPlugins: state =>
+ state.plugins.filter(plugin => plugin.frontend_studio_enabled),
+ projectPlugins: state =>
+ state.plugins.filter(plugin => plugin.frontend_project_enabled)
}
const actions = {
@@ -389,6 +398,7 @@ const actions = {
commit(SET_CURRENT_PRODUCTION, rootGetters.currentProduction.id)
}
commit(LOAD_TASK_TYPES_END, context.task_types)
+ commit(LOAD_PLUGINS_END, context.plugins)
})
}
}
@@ -821,6 +831,10 @@ const mutations = {
}
},
+ [LOAD_PLUGINS_END](state, plugins) {
+ state.plugins = plugins
+ },
+
[CLEAR_AVATAR](state, userId) {
if (state.user.id === userId) {
state.user.has_avatar = false
@@ -836,7 +850,7 @@ const mutations = {
}
export default {
- namespace: true,
+ namespaced: false,
state,
getters,
actions,
diff --git a/src/store/mutation-types.js b/src/store/mutation-types.js
index ec80e14340..cba357a51d 100644
--- a/src/store/mutation-types.js
+++ b/src/store/mutation-types.js
@@ -291,6 +291,10 @@ export const LOAD_STUDIOS_END = 'LOAD_STUDIOS_END'
export const EDIT_STUDIOS_END = 'EDIT_STUDIOS_END'
export const DELETE_STUDIOS_END = 'DELETE_STUDIOS_END'
+// Plugins
+
+export const LOAD_PLUGINS_END = 'LOAD_PLUGINS_END'
+
// Assets
export const CLEAR_ASSETS = 'CLEAR_ASSETS'
From cde33d10d5f1604a09d6b5510a2af62be4f27f0e Mon Sep 17 00:00:00 2001
From: Frank Rousseau
Date: Mon, 29 Dec 2025 21:48:04 +0100
Subject: [PATCH 2/6] [ux] Add lucide icon versatile component
---
src/components/widgets/Icon.vue | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
create mode 100644 src/components/widgets/Icon.vue
diff --git a/src/components/widgets/Icon.vue b/src/components/widgets/Icon.vue
new file mode 100644
index 0000000000..2b9403dc9e
--- /dev/null
+++ b/src/components/widgets/Icon.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
From 3840e3763597367eb247d392eee08291c433f9d0 Mon Sep 17 00:00:00 2001
From: Frank Rousseau
Date: Mon, 29 Dec 2025 21:51:52 +0100
Subject: [PATCH 3/6] [user] Remove wrong option in user store
---
src/store/modules/user.js | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/store/modules/user.js b/src/store/modules/user.js
index 6f45cfe621..2922ebfa61 100644
--- a/src/store/modules/user.js
+++ b/src/store/modules/user.js
@@ -850,7 +850,6 @@ const mutations = {
}
export default {
- namespaced: false,
state,
getters,
actions,
From 83dece366112ece53704ca6a1df898eebb584c46 Mon Sep 17 00:00:00 2001
From: Frank Rousseau
Date: Mon, 5 Jan 2026 20:55:07 +0100
Subject: [PATCH 4/6] [playlists] Allow to filter client notifs on departments
---
src/components/modals/NotifyClientModal.vue | 22 +++++++++++++++++--
.../pages/playlists/PlaylistPlayer.vue | 5 +++--
src/components/widgets/ComboboxDepartment.vue | 8 ++++++-
src/locales/en.js | 2 +-
src/store/api/playlists.js | 4 ++--
src/store/modules/playlists.js | 4 ++--
6 files changed, 35 insertions(+), 10 deletions(-)
diff --git a/src/components/modals/NotifyClientModal.vue b/src/components/modals/NotifyClientModal.vue
index f12b32218c..2b3f4c3a93 100644
--- a/src/components/modals/NotifyClientModal.vue
+++ b/src/components/modals/NotifyClientModal.vue
@@ -16,6 +16,14 @@
v-model="form.studio_id"
/>
+
+
{{ $t('playlists.clients_to_notify') }}
@@ -59,6 +67,7 @@ import { mapGetters } from 'vuex'
import { modalMixin } from '@/components/modals/base_modal'
import BaseModal from '@/components/modals/BaseModal.vue'
+import ComboboxDepartment from '@/components/widgets/ComboboxDepartment.vue'
import ComboboxStudio from '@/components/widgets/ComboboxStudio.vue'
import ModalFooter from '@/components/modals/ModalFooter.vue'
import PeopleAvatar from '../widgets/PeopleAvatar.vue'
@@ -71,6 +80,7 @@ export default {
components: {
BaseModal,
+ ComboboxDepartment,
ComboboxStudio,
ModalFooter,
PeopleAvatar,
@@ -105,7 +115,8 @@ export default {
data() {
return {
form: {
- studio_id: ''
+ studio_id: '',
+ department_id: ''
}
}
},
@@ -121,13 +132,20 @@ export default {
person =>
!this.form.studio_id || person.studio_id === this.form.studio_id
)
+ .filter(
+ person =>
+ !this.form.department_id ||
+ (person.departments &&
+ person.departments.includes(this.form.department_id))
+ )
}
},
methods: {
runConfirmation() {
this.$emit('confirm', {
- studio_id: this.form.studio_id
+ studio_id: this.form.studio_id,
+ department_id: this.form.department_id
})
}
}
diff --git a/src/components/pages/playlists/PlaylistPlayer.vue b/src/components/pages/playlists/PlaylistPlayer.vue
index d2d91485f3..4f6247521c 100644
--- a/src/components/pages/playlists/PlaylistPlayer.vue
+++ b/src/components/pages/playlists/PlaylistPlayer.vue
@@ -1482,14 +1482,15 @@ export default {
this.errors.notifyClients = false
},
- async confirmNotifyClients({ studioId }) {
+ async confirmNotifyClients({ studioId, departmentId }) {
this.loading.notifyClients = true
this.errors.notifyClients = false
this.success.notifyClients = false
try {
await this.notifyClients({
playlist: this.playlist,
- studioId
+ studioId,
+ departmentId
})
this.success.notifyClients = true
} catch (err) {
diff --git a/src/components/widgets/ComboboxDepartment.vue b/src/components/widgets/ComboboxDepartment.vue
index b5b54e5e20..375ea7ac92 100644
--- a/src/components/widgets/ComboboxDepartment.vue
+++ b/src/components/widgets/ComboboxDepartment.vue
@@ -109,6 +109,10 @@ export default {
top: {
default: false,
type: Boolean
+ },
+ allDepartmentsLabel: {
+ default: false,
+ type: Boolean
}
},
@@ -159,7 +163,9 @@ export default {
{
color: '#AAA',
id: null,
- name: this.$t('departments.no_department')
+ name: this.allDepartmentsLabel
+ ? this.$t('departments.all_departments')
+ : this.$t('departments.no_department')
},
...this.departmentsToTakeAccount
]
diff --git a/src/locales/en.js b/src/locales/en.js
index fab34dbc9e..dd954882d5 100644
--- a/src/locales/en.js
+++ b/src/locales/en.js
@@ -352,7 +352,7 @@ export default {
},
departments: {
- all_asset_types: 'All departments',
+ all_departments: 'All departments',
available_items: 'Available Items',
create_error: 'An error occurred while saving this department. Are you sure there is no department with a similar name?',
delete_text: 'Are you sure you want to remove {name} from your database?',
diff --git a/src/store/api/playlists.js b/src/store/api/playlists.js
index a11e4bfc72..45db54ad4e 100644
--- a/src/store/api/playlists.js
+++ b/src/store/api/playlists.js
@@ -94,8 +94,8 @@ export default {
return client.ppost(path, { task_ids: taskIds })
},
- notifyClients(playlist, studioId) {
- const data = { studio_id: studioId }
+ notifyClients(playlist, studioId, departmentId) {
+ const data = { studio_id: studioId, department_id: departmentId }
return client.ppost(
`/api/data/playlists/${playlist.id}/notify-clients`,
data
diff --git a/src/store/modules/playlists.js b/src/store/modules/playlists.js
index 746c580df4..8fc5b5a7aa 100644
--- a/src/store/modules/playlists.js
+++ b/src/store/modules/playlists.js
@@ -249,8 +249,8 @@ const actions = {
return playlistsApi.updatePreviewFileValidationStatus(previewFile, status)
},
- notifyClients({ commit }, { playlist, studioId }) {
- return playlistsApi.notifyClients(playlist, studioId)
+ notifyClients({ commit }, { playlist, studioId, departmentId }) {
+ return playlistsApi.notifyClients(playlist, studioId, departmentId)
}
}
From 8d5522870322644bc3bff1c15303861dac097876 Mon Sep 17 00:00:00 2001
From: Frank Rousseau
Date: Tue, 6 Jan 2026 21:51:19 +0100
Subject: [PATCH 5/6] [previews] Use cgwire version of the annotation brush
---
package.json | 2 +-
src/components/mixins/annotation.js | 2 +-
src/components/previews/PreviewPlayer.vue | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index fb0c9ab448..b313771c6f 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,6 @@
},
"dependencies": {
"@animxyz/vue3": "0.6.7",
- "@arch-inc/fabricjs-psbrush": "0.0.20",
"@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19",
"@fullcalendar/multimonth": "6.1.19",
@@ -39,6 +38,7 @@
"core-js": "3.47.0",
"exceljs": "4.4.0",
"fabric": "cgwire/fabric.js",
+ "fabricjs-psbrush": "cgwire/fabricjs-psbrush",
"file-saver": "2.0.5",
"lucide-vue-next": "0.562.0",
"marked": "17.0.1",
diff --git a/src/components/mixins/annotation.js b/src/components/mixins/annotation.js
index a9b1442def..d6e16328c2 100644
--- a/src/components/mixins/annotation.js
+++ b/src/components/mixins/annotation.js
@@ -4,7 +4,7 @@
*/
import { fabric } from 'fabric'
import moment from 'moment'
-import { PSStroke, PSBrush } from '@arch-inc/fabricjs-psbrush'
+import { PSStroke, PSBrush } from 'fabricjs-psbrush'
import { v4 as uuidv4 } from 'uuid'
import { markRaw } from 'vue'
diff --git a/src/components/previews/PreviewPlayer.vue b/src/components/previews/PreviewPlayer.vue
index a77c84a27c..a4817d0a74 100644
--- a/src/components/previews/PreviewPlayer.vue
+++ b/src/components/previews/PreviewPlayer.vue
@@ -560,7 +560,7 @@