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 @@ + + + + + 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 @@

+
+

+ + + {{ plugin.name }} + +

+

{{ $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 @@