@saooti/octopus-sdk 41.9.6 → 41.10.1
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/CHANGELOG.md +40 -0
- package/index.ts +10 -3
- package/package.json +1 -1
- package/src/api/aggregatorsApi.ts +53 -0
- package/src/api/podcastApi.ts +1 -1
- package/src/api/radioApi.ts +19 -0
- package/src/components/composable/share/useSharePlatforms.ts +112 -82
- package/src/components/composable/useRights.ts +21 -0
- package/src/components/composable/useTranslation.ts +9 -7
- package/src/components/display/live/RadioCurrently.vue +1 -0
- package/src/components/display/live/RadioImage.vue +3 -3
- package/src/components/display/sharing/SubscribeButtons.vue +6 -1
- package/src/components/form/ClassicButtonGroup.vue +71 -0
- package/src/components/form/ClassicInputText.vue +10 -3
- package/src/components/form/ClassicRadio.vue +15 -4
- package/src/components/misc/ClassicAlert.vue +2 -0
- package/src/components/misc/ClassicAvatar.vue +62 -0
- package/src/components/misc/ClassicNotifications.vue +12 -9
- package/src/components/misc/TopBar.vue +1 -1
- package/src/helper/colorFromString.ts +15 -0
- package/src/stores/NotificationStore.ts +54 -0
- package/src/stores/PlayerStore.ts +1 -1
- package/src/style/_utilities.scss +7 -1
- package/tests/components/composable/useRights.spec.ts +18 -0
- package/tests/components/composable/useTranslation.spec.ts +27 -3
- package/tests/components/form/ClassicButtonGroup.spec.ts +52 -0
- package/tests/components/form/ClassicInputText.spec.ts +23 -0
- package/tests/components/misc/ClassicAvatar.spec.ts +68 -0
- package/src/components/composable/useNotifications.ts +0 -50
- package/src/stores/class/rss/aggregator.ts +0 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 41.10.1 (04/05/2026)
|
|
4
|
+
|
|
5
|
+
**Fixes**
|
|
6
|
+
|
|
7
|
+
- **14469** - Correction récupération incorrecte de la configuration de
|
|
8
|
+
traduction de l'organisation
|
|
9
|
+
|
|
10
|
+
## 41.10.0 (04/05/2026)
|
|
11
|
+
|
|
12
|
+
**Features**
|
|
13
|
+
|
|
14
|
+
- **14384** - Changements système d'aggregateurs
|
|
15
|
+
- Mise en place de `aggregatorsApi` pour faciliter les requêtes
|
|
16
|
+
- Ajout de fonctions utilitaires pour les droits
|
|
17
|
+
- Intégration des plateformes personnalisées à `useSharePlatforms`
|
|
18
|
+
- Ajout d'un composant `ClassicAvatar` pour identifier des éléments avec une
|
|
19
|
+
image ou lettre sur fond coloré
|
|
20
|
+
- Ajout event `blur` sur `ClassicInputText`
|
|
21
|
+
- Amélioration accessibilité `ClassicInputText`
|
|
22
|
+
- Ajout composant `ClassicButtonGroup`
|
|
23
|
+
- `ClassicRadio` calcule automatiquement un id si non défini
|
|
24
|
+
- Ajout `radioApi`
|
|
25
|
+
- Ajout d'un helper `gap-X` pour contrôler l'espacement des éléments flex
|
|
26
|
+
- Fonctionne comme le helper de marges `mt-X`
|
|
27
|
+
- Utilisation d'un store à la place d'un composable pour la gestion des
|
|
28
|
+
notifications
|
|
29
|
+
|
|
30
|
+
**Fixes**
|
|
31
|
+
|
|
32
|
+
- **14353** - Correction erreur sur page radio
|
|
33
|
+
- **14354** - Correction recherche par tag
|
|
34
|
+
- Correction timer dans `ClassicInputText` faisant échouer les tests de manière
|
|
35
|
+
aléatoire
|
|
36
|
+
|
|
37
|
+
**Misc**
|
|
38
|
+
|
|
39
|
+
- Ajustement affichage bouton play de `RadioImage`
|
|
40
|
+
- Correction vulnérabilité xmidom
|
|
41
|
+
- Mise à jour dépendances
|
|
42
|
+
|
|
3
43
|
## 41.9.6 (20/04/2026)
|
|
4
44
|
|
|
5
45
|
**Features**
|
package/index.ts
CHANGED
|
@@ -55,6 +55,11 @@ export {
|
|
|
55
55
|
import ActionButton from "./src/components/buttons/ActionButton.vue";
|
|
56
56
|
export { ActionButton };
|
|
57
57
|
|
|
58
|
+
// Form
|
|
59
|
+
import ClassicButtonGroup from "./src/components/form/ClassicButtonGroup.vue";
|
|
60
|
+
export { ClassicButtonGroup };
|
|
61
|
+
export type { ButtonGroupOption } from "./src/components/form/ClassicButtonGroup.vue";
|
|
62
|
+
|
|
58
63
|
//Display
|
|
59
64
|
export const getCategoryChooser = () => import("./src/components/display/categories/CategoryChooser.vue");
|
|
60
65
|
export const getCategoryList = () => import("./src/components/display/categories/CategoryList.vue");
|
|
@@ -129,8 +134,7 @@ import {useOrganisationFilter} from "./src/components/composable/useOrganisation
|
|
|
129
134
|
import {useInit} from "./src/components/composable/useInit.ts";
|
|
130
135
|
import {useErrorHandler} from "./src/components/composable/useErrorHandler.ts";
|
|
131
136
|
import { useSimplePageParam } from "./src/components/composable/route/useSimplePageParam";
|
|
132
|
-
|
|
133
|
-
export { useSharePlatforms, SharePlatformName, type SharePlatform } from "./src/components/composable/share/useSharePlatforms.ts";
|
|
137
|
+
export { useSharePlatforms, PREDEFINED_PLATFORMS, SharePlatformName, type SharePlatform } from "./src/components/composable/share/useSharePlatforms.ts";
|
|
134
138
|
export { useSharePath } from "./src/components/composable/share/useSharePath.ts";
|
|
135
139
|
export { useOrgaComputed } from "./src/components/composable/useOrgaComputed.ts";
|
|
136
140
|
export { useSeoTitleUrl } from "./src/components/composable/route/useSeoTitleUrl.ts";
|
|
@@ -158,6 +162,7 @@ import {useFilterStore} from "./src/stores/FilterStore.ts";
|
|
|
158
162
|
import {useCommentStore} from "./src/stores/CommentStore.ts";
|
|
159
163
|
import {useApiStore} from "./src/stores/ApiStore.ts";
|
|
160
164
|
import {useAuthStore} from "./src/stores/AuthStore.ts";
|
|
165
|
+
export * from "./src/stores/NotificationStore.ts";
|
|
161
166
|
import {getApiUrl, ModuleApi} from "./src/api/apiConnection.ts";
|
|
162
167
|
import classicApi from "./src/api/classicApi.ts";
|
|
163
168
|
|
|
@@ -168,6 +173,8 @@ export * from "./src/api/groupsApi.ts";
|
|
|
168
173
|
export { organisationApi } from "./src/api/organisationApi.ts";
|
|
169
174
|
export { playlistApi } from "./src/api/playlistApi.ts";
|
|
170
175
|
export { podcastApi, PodcastSort, type PodcastSearchOptions } from "./src/api/podcastApi.ts";
|
|
176
|
+
export { radioApi } from "./src/api/radioApi.ts";
|
|
177
|
+
export * from "./src/api/aggregatorsApi.ts";
|
|
171
178
|
|
|
172
179
|
// Types
|
|
173
180
|
export { type Emission, SeasonMode, emptyEmissionData } from "./src/stores/class/general/emission.ts";
|
|
@@ -188,6 +195,7 @@ export {
|
|
|
188
195
|
type ProviderTts,
|
|
189
196
|
type Voice
|
|
190
197
|
} from "./src/stores/class/transcript/transcriptParams.ts";
|
|
198
|
+
export { type Canal } from "./src/stores/class/radio/canal.ts";
|
|
191
199
|
|
|
192
200
|
//Icons
|
|
193
201
|
export const getAmazonMusicIcon = () => import("./src/components/icons/AmazonMusicIcon.vue");
|
|
@@ -233,7 +241,6 @@ export {
|
|
|
233
241
|
useInit,
|
|
234
242
|
useErrorHandler,
|
|
235
243
|
useSimplePageParam,
|
|
236
|
-
useNotifications,
|
|
237
244
|
debounce,
|
|
238
245
|
useVastStore,
|
|
239
246
|
useSaveFetchStore,
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ModuleApi } from "./apiConnection";
|
|
2
|
+
import classicApi from "./classicApi";
|
|
3
|
+
|
|
4
|
+
export interface Aggregator {
|
|
5
|
+
/** ID of the aggregator */
|
|
6
|
+
aggregatorId: number;
|
|
7
|
+
/** Name of the aggregator */
|
|
8
|
+
name: string;
|
|
9
|
+
/** User who created this aggregator */
|
|
10
|
+
createdBy: string;
|
|
11
|
+
/** Link to image for aggregator */
|
|
12
|
+
image?: string;
|
|
13
|
+
/** IP criteria */
|
|
14
|
+
ip?: string;
|
|
15
|
+
/** IP range criteria */
|
|
16
|
+
ipRange?: string;
|
|
17
|
+
/** Regex for referer criteria */
|
|
18
|
+
refererRegexp?: string;
|
|
19
|
+
/** Slug criteria */
|
|
20
|
+
slug?: string;
|
|
21
|
+
/** Regex for user agent criteria */
|
|
22
|
+
uaRegexp?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PredefinedAggregator = Pick<Aggregator, 'name' | 'image'>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Return the list of predefined aggregators
|
|
29
|
+
*/
|
|
30
|
+
async function getPredefined(): Promise<Array<PredefinedAggregator>> {
|
|
31
|
+
return classicApi.fetchData<Array<PredefinedAggregator>>({
|
|
32
|
+
api: ModuleApi.DEFAULT,
|
|
33
|
+
path: 'rss/aggregator/distributions'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return the list of all user-defined aggregators
|
|
39
|
+
* @param organisationId If defined, the aggregators will be filtered for this
|
|
40
|
+
* organisation
|
|
41
|
+
*/
|
|
42
|
+
async function getAll(organisationId?: string): Promise<Array<Aggregator>> {
|
|
43
|
+
return classicApi.fetchData<Array<Aggregator>>({
|
|
44
|
+
api: ModuleApi.DEFAULT,
|
|
45
|
+
path: 'rss/aggregator/list',
|
|
46
|
+
parameters: organisationId ? { organisationId } : undefined
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const aggregatorsApi = {
|
|
51
|
+
getPredefined,
|
|
52
|
+
getAll
|
|
53
|
+
};
|
package/src/api/podcastApi.ts
CHANGED
|
@@ -120,7 +120,7 @@ function processSearchParameters(search: PodcastSearchOptions): FetchParam {
|
|
|
120
120
|
} else if (key === 'processingStatus') {
|
|
121
121
|
parameters.includeStatus = value;
|
|
122
122
|
} else if (key === 'tags') {
|
|
123
|
-
parameters.
|
|
123
|
+
parameters.includeTag = value;
|
|
124
124
|
} else if (key === 'pubDateBefore') {
|
|
125
125
|
parameters.before = value;
|
|
126
126
|
} else if (key === 'pubDateAfter') {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Canal } from "../stores/class/radio/canal";
|
|
2
|
+
import classicApi from "./classicApi";
|
|
3
|
+
import { ModuleApi } from "./apiConnection";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Retrieve a radio canal by its id
|
|
7
|
+
* @param radioId Id of the radio to retrieve
|
|
8
|
+
* @returns The canal data
|
|
9
|
+
*/
|
|
10
|
+
async function get(radioId: number): Promise<Canal> {
|
|
11
|
+
return classicApi.fetchData<Canal>({
|
|
12
|
+
api: ModuleApi.RADIO,
|
|
13
|
+
path: `canal/${radioId}`
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const radioApi = {
|
|
18
|
+
get
|
|
19
|
+
};
|
|
@@ -14,7 +14,11 @@ import RadioFranceIcon from "../../icons/RadioFranceIcon.vue";
|
|
|
14
14
|
import YoutubeIcon from "vue-material-design-icons/Youtube.vue";
|
|
15
15
|
import SpotifyIcon from "vue-material-design-icons/Spotify.vue";
|
|
16
16
|
import { Annotations } from "@/stores/class/general";
|
|
17
|
-
import {
|
|
17
|
+
import { h, markRaw, onMounted, ref, type Component } from "vue";
|
|
18
|
+
import { aggregatorsApi } from "../../../api/aggregatorsApi";
|
|
19
|
+
import { useAuthStore } from "../../../stores/AuthStore";
|
|
20
|
+
import { useNotificationStore } from "../../../stores/NotificationStore";
|
|
21
|
+
import ClassicAvatar from "../../misc/ClassicAvatar.vue";
|
|
18
22
|
|
|
19
23
|
export enum SharePlatformName {
|
|
20
24
|
APPLE = "applePodcast",
|
|
@@ -36,13 +40,15 @@ export enum SharePlatformName {
|
|
|
36
40
|
|
|
37
41
|
export interface SharePlatform {
|
|
38
42
|
/** ID of the platform */
|
|
39
|
-
name: SharePlatformName;
|
|
43
|
+
name: SharePlatformName|string;
|
|
40
44
|
/** Icon of the platform */
|
|
41
45
|
icon: Component;
|
|
42
46
|
/** Display text/title for buttons */
|
|
43
47
|
title: string;
|
|
44
48
|
/** Color of the icon */
|
|
45
49
|
color: string;
|
|
50
|
+
/** Annotation to use for url */
|
|
51
|
+
annotation?: string;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export interface SharePlatformUrl extends SharePlatform {
|
|
@@ -50,87 +56,111 @@ export interface SharePlatformUrl extends SharePlatform {
|
|
|
50
56
|
url: string;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
export const PREDEFINED_PLATFORMS: Array<SharePlatform> = [{
|
|
60
|
+
name: SharePlatformName.APPLE,
|
|
61
|
+
icon: markRaw(ApplePodcastIcon),
|
|
62
|
+
title: "Apple Podcast | iTunes",
|
|
63
|
+
color: "#aa1dd3"
|
|
64
|
+
}, {
|
|
65
|
+
name: SharePlatformName.DEEZER,
|
|
66
|
+
icon: markRaw(DeezerIcon),
|
|
67
|
+
title: "Deezer",
|
|
68
|
+
color: "#a238ff",
|
|
69
|
+
}, {
|
|
70
|
+
name: SharePlatformName.SPOTIFY,
|
|
71
|
+
icon: markRaw(SpotifyIcon),
|
|
72
|
+
title: "Spotify",
|
|
73
|
+
color: "#1ed760",
|
|
74
|
+
}, {
|
|
75
|
+
name: SharePlatformName.AMAZON,
|
|
76
|
+
icon: markRaw(AmazonMusicIcon),
|
|
77
|
+
title: "Amazon Music",
|
|
78
|
+
color: "#0c6cb3",
|
|
79
|
+
}, {
|
|
80
|
+
name: SharePlatformName.I_HEART,
|
|
81
|
+
icon: markRaw(IHeartIcon),
|
|
82
|
+
title: "iHeart",
|
|
83
|
+
color: "#e11b22"
|
|
84
|
+
}, {
|
|
85
|
+
name: SharePlatformName.PLAYER_FM,
|
|
86
|
+
icon: markRaw(PlayerFmIcon),
|
|
87
|
+
title: "Player FM",
|
|
88
|
+
color: "#bb202a"
|
|
89
|
+
}, {
|
|
90
|
+
name: SharePlatformName.POCKET_CASTS,
|
|
91
|
+
icon: markRaw(PocketCastIcon),
|
|
92
|
+
title: "Pocket Casts",
|
|
93
|
+
color: "#f43e37"
|
|
94
|
+
}, {
|
|
95
|
+
name: SharePlatformName.PODCAST_ADDICT,
|
|
96
|
+
icon: markRaw(PodcastAddictIcon),
|
|
97
|
+
title: "Podcast Addict",
|
|
98
|
+
color: "#f4842d"
|
|
99
|
+
}, {
|
|
100
|
+
name: SharePlatformName.RADIOLINE,
|
|
101
|
+
icon: markRaw(RadiolineIcon),
|
|
102
|
+
title: "Radioline",
|
|
103
|
+
color: "#1678bd"
|
|
104
|
+
}, {
|
|
105
|
+
name: SharePlatformName.TUNE_IN,
|
|
106
|
+
icon: markRaw(TuninIcon),
|
|
107
|
+
title: "TuneIn",
|
|
108
|
+
color: "#36b4a7"
|
|
109
|
+
}, {
|
|
110
|
+
name: SharePlatformName.YOUTUBE,
|
|
111
|
+
icon: markRaw(YoutubeIcon),
|
|
112
|
+
title: "YouTube Music",
|
|
113
|
+
color: "#fe0000",
|
|
114
|
+
}, {
|
|
115
|
+
name: SharePlatformName.CASTBOX,
|
|
116
|
+
icon: markRaw(CastboxIcon),
|
|
117
|
+
title: "Castbox",
|
|
118
|
+
color: "#fe6222",
|
|
119
|
+
}, {
|
|
120
|
+
name: SharePlatformName.PODBEAN,
|
|
121
|
+
icon: markRaw(PodbeanIcon),
|
|
122
|
+
title: "PodBean",
|
|
123
|
+
color: "#428200",
|
|
124
|
+
}, {
|
|
125
|
+
name: SharePlatformName.PODCAST_REPUBLIC,
|
|
126
|
+
icon: markRaw(PodcastRepublicIcon),
|
|
127
|
+
title: "Podcast Republic",
|
|
128
|
+
color: "#5c85dd",
|
|
129
|
+
}, {
|
|
130
|
+
name: SharePlatformName.RADIO_FRANCE,
|
|
131
|
+
icon: markRaw(RadioFranceIcon),
|
|
132
|
+
title: "Radio France",
|
|
133
|
+
color: "#a90041",
|
|
134
|
+
}];
|
|
135
|
+
|
|
53
136
|
export const useSharePlatforms = () => {
|
|
54
|
-
|
|
55
|
-
const platforms = computed((): Array<SharePlatform> => {
|
|
56
|
-
return [{
|
|
57
|
-
name: SharePlatformName.APPLE,
|
|
58
|
-
icon: markRaw(ApplePodcastIcon),
|
|
59
|
-
title: "Apple Podcast | iTunes",
|
|
60
|
-
color:"#aa1dd3"
|
|
61
|
-
}, {
|
|
62
|
-
name: SharePlatformName.DEEZER,
|
|
63
|
-
icon: markRaw(DeezerIcon),
|
|
64
|
-
title: "Deezer",
|
|
65
|
-
color:"#a238ff",
|
|
66
|
-
}, {
|
|
67
|
-
name: SharePlatformName.SPOTIFY,
|
|
68
|
-
icon: markRaw(SpotifyIcon),
|
|
69
|
-
title: "Spotify",
|
|
70
|
-
color: "#1ed760",
|
|
71
|
-
}, {
|
|
72
|
-
name: SharePlatformName.AMAZON,
|
|
73
|
-
icon: markRaw(AmazonMusicIcon),
|
|
74
|
-
title: "Amazon Music",
|
|
75
|
-
color: "#0c6cb3",
|
|
76
|
-
}, {
|
|
77
|
-
name: SharePlatformName.I_HEART,
|
|
78
|
-
icon: markRaw(IHeartIcon),
|
|
79
|
-
title: "iHeart",
|
|
80
|
-
color:"#e11b22"
|
|
81
|
-
}, {
|
|
82
|
-
name: SharePlatformName.PLAYER_FM,
|
|
83
|
-
icon: markRaw(PlayerFmIcon),
|
|
84
|
-
title: "Player FM",
|
|
85
|
-
color:"#bb202a"
|
|
86
|
-
}, {
|
|
87
|
-
name: SharePlatformName.POCKET_CASTS,
|
|
88
|
-
icon: markRaw(PocketCastIcon),
|
|
89
|
-
title: "Pocket Casts",
|
|
90
|
-
color:"#f43e37"
|
|
91
|
-
}, {
|
|
92
|
-
name: SharePlatformName.PODCAST_ADDICT,
|
|
93
|
-
icon: markRaw(PodcastAddictIcon),
|
|
94
|
-
title: "Podcast Addict",
|
|
95
|
-
color:"#f4842d"
|
|
96
|
-
}, {
|
|
97
|
-
name: SharePlatformName.RADIOLINE,
|
|
98
|
-
icon: markRaw(RadiolineIcon),
|
|
99
|
-
title: "Radioline",
|
|
100
|
-
color:"#1678bd"
|
|
101
|
-
}, {
|
|
102
|
-
name: SharePlatformName.TUNE_IN,
|
|
103
|
-
icon: markRaw(TuninIcon),
|
|
104
|
-
title: "TuneIn",
|
|
105
|
-
color:"#36b4a7"
|
|
106
|
-
}, {
|
|
107
|
-
name: SharePlatformName.YOUTUBE,
|
|
108
|
-
icon: markRaw(YoutubeIcon),
|
|
109
|
-
title: "YouTube Music",
|
|
110
|
-
color: "#fe0000",
|
|
111
|
-
}, {
|
|
112
|
-
name: SharePlatformName.CASTBOX,
|
|
113
|
-
icon: markRaw(CastboxIcon),
|
|
114
|
-
title: "Castbox",
|
|
115
|
-
color: "#fe6222",
|
|
116
|
-
}, {
|
|
117
|
-
name: SharePlatformName.PODBEAN,
|
|
118
|
-
icon: markRaw(PodbeanIcon),
|
|
119
|
-
title: "PodBean",
|
|
120
|
-
color: "#428200",
|
|
121
|
-
}, {
|
|
122
|
-
name: SharePlatformName.PODCAST_REPUBLIC,
|
|
123
|
-
icon: markRaw(PodcastRepublicIcon),
|
|
124
|
-
title: "Podcast Republic",
|
|
125
|
-
color: "#5c85dd",
|
|
126
|
-
}, {
|
|
127
|
-
name: SharePlatformName.RADIO_FRANCE,
|
|
128
|
-
icon: markRaw(RadioFranceIcon),
|
|
129
|
-
title: "Radio France",
|
|
130
|
-
color: "#a90041",
|
|
131
|
-
}];
|
|
132
|
-
});
|
|
133
137
|
|
|
138
|
+
const platforms = ref<Array<SharePlatform>>([]);
|
|
139
|
+
|
|
140
|
+
const { authOrgaId } = useAuthStore();
|
|
141
|
+
const { addNotification } = useNotificationStore();
|
|
142
|
+
|
|
143
|
+
onMounted(async (): Promise<void> => {
|
|
144
|
+
platforms.value.push(...PREDEFINED_PLATFORMS);
|
|
145
|
+
try {
|
|
146
|
+
const customPlatforms = await aggregatorsApi.getAll(authOrgaId);
|
|
147
|
+
customPlatforms.forEach(platform => {
|
|
148
|
+
platforms.value.push({
|
|
149
|
+
name: platform.name,
|
|
150
|
+
title: platform.name,
|
|
151
|
+
icon: markRaw(() => h(ClassicAvatar, { name: platform.name, imageUrl: platform.image })),
|
|
152
|
+
color: "white",
|
|
153
|
+
annotation: `ptfaudio_${platform.name}`
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
} catch(error) {
|
|
157
|
+
addNotification({
|
|
158
|
+
type: 'error',
|
|
159
|
+
message: error
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
134
164
|
/**
|
|
135
165
|
* Helper to retrieve configuration of a specific platform
|
|
136
166
|
* @param platform The ID of the platform
|
|
@@ -149,7 +179,7 @@ export const useSharePlatforms = () => {
|
|
|
149
179
|
function getPlatformsWithLinks(annotations: Annotations|undefined): Array<SharePlatformUrl> {
|
|
150
180
|
const ary: Array<SharePlatformUrl> = [];
|
|
151
181
|
platforms.value.forEach(p => {
|
|
152
|
-
const url = getUrl(p.name, annotations);
|
|
182
|
+
const url = getUrl(p.annotation ?? p.name, annotations);
|
|
153
183
|
if (url) {
|
|
154
184
|
ary.push({
|
|
155
185
|
...p,
|
|
@@ -12,6 +12,7 @@ export enum EditRight {
|
|
|
12
12
|
Restricted, // User cannot edit because element is used elsewhere
|
|
13
13
|
Full // User can edit
|
|
14
14
|
}
|
|
15
|
+
|
|
15
16
|
/**
|
|
16
17
|
* Composable to manage rights.
|
|
17
18
|
* Based on AuthStore, but converts roles to easily usable tests for various
|
|
@@ -177,6 +178,21 @@ export const useRights = () => {
|
|
|
177
178
|
return roleContainsAny('RESTRICTED_PRODUCTION') && !roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION');
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
/** Can the current user create an aggregator */
|
|
182
|
+
function canCreateAggregator(): boolean {
|
|
183
|
+
return roleContainsAny('ADMIN', 'ORGANISATION');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Can the current user edit an aggregator */
|
|
187
|
+
function canEditAggregator(): boolean {
|
|
188
|
+
return roleContainsAny('ADMIN', 'ORGANISATION');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Can the current user delete an aggregator */
|
|
192
|
+
function canDeleteAggregator(): boolean {
|
|
193
|
+
return roleContainsAny('ADMIN', 'ORGANISATION');
|
|
194
|
+
}
|
|
195
|
+
|
|
180
196
|
return {
|
|
181
197
|
// Emissions
|
|
182
198
|
canCreateEmission,
|
|
@@ -203,6 +219,11 @@ export const useRights = () => {
|
|
|
203
219
|
canEditParticipant,
|
|
204
220
|
canDeleteParticipant,
|
|
205
221
|
|
|
222
|
+
// Aggregators
|
|
223
|
+
canCreateAggregator,
|
|
224
|
+
canEditAggregator,
|
|
225
|
+
canDeleteAggregator,
|
|
226
|
+
|
|
206
227
|
// Other
|
|
207
228
|
canEditCodeInsertPlayer,
|
|
208
229
|
canEditTranscript,
|
|
@@ -3,6 +3,8 @@ import { transcriptionApi, TranslationState, type PodcastTranslationData } from
|
|
|
3
3
|
import { CreateTranslation, defaultTranslationConfig, TranslationConfiguration } from "../../stores/class/transcript/transcriptParams";
|
|
4
4
|
import { podcastApi } from "../../api/podcastApi";
|
|
5
5
|
import { Emission } from "../../stores/class/general/emission";
|
|
6
|
+
import { OrganisationAttributes } from "@/stores/class/general/organisation";
|
|
7
|
+
import { organisationApi } from "../../api/organisationApi";
|
|
6
8
|
|
|
7
9
|
const DEFAULT_LANGUAGE = 'en';
|
|
8
10
|
|
|
@@ -67,21 +69,20 @@ export const useTranslation = () => {
|
|
|
67
69
|
* @param emission *(optional)* If set, will also check in emission settings
|
|
68
70
|
* @returns Whether the language is available or not
|
|
69
71
|
*/
|
|
70
|
-
function getTranslationConfig(language: string, emission: Emission): CreateTranslation {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const orgTranslation = parseOrDefault(orgAttributes?.['translation-config']);
|
|
72
|
+
function getTranslationConfig(language: string, emission: Emission, orgaAttributes: OrganisationAttributes): CreateTranslation {
|
|
73
|
+
const emissionTranslation = parseOrDefault(emission.annotations?.['translation-config'] as string|undefined, true);
|
|
74
|
+
const orgTranslation = parseOrDefault(orgaAttributes?.['translation-config']);
|
|
74
75
|
|
|
75
76
|
return getConfigurationFor(language, emissionTranslation, orgTranslation);
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
function getLanguageAvailability(language: string, emission: Emission, translationData: PodcastTranslationData): Availability {
|
|
79
|
+
function getLanguageAvailability(language: string, emission: Emission, orgaAttributes: OrganisationAttributes, translationData: PodcastTranslationData): Availability {
|
|
79
80
|
|
|
80
81
|
// 1. If language of podcast == language of browser, use that language
|
|
81
82
|
if (language === translationData.nativeLanguage) {
|
|
82
83
|
return Availability.Available;
|
|
83
84
|
} else {
|
|
84
|
-
const langConfig = getTranslationConfig(language, emission);
|
|
85
|
+
const langConfig = getTranslationConfig(language, emission, orgaAttributes);
|
|
85
86
|
|
|
86
87
|
// 2. If the language of the browser is available, use it
|
|
87
88
|
if (langConfig === CreateTranslation.ALWAYS) {
|
|
@@ -136,12 +137,13 @@ export const useTranslation = () => {
|
|
|
136
137
|
|
|
137
138
|
const podcast = await podcastApi.get(translationData.podcastId);
|
|
138
139
|
const emission = podcast.emission;
|
|
140
|
+
const orgaAttributes = await organisationApi.getAttributes(podcast.organisation.id);
|
|
139
141
|
|
|
140
142
|
let bestReady: string|null = null;
|
|
141
143
|
let bestAvailable: string|null = null;
|
|
142
144
|
|
|
143
145
|
for (const language of languages) {
|
|
144
|
-
const avaibility = getLanguageAvailability(language, emission, translationData);
|
|
146
|
+
const avaibility = getLanguageAvailability(language, emission, orgaAttributes, translationData);
|
|
145
147
|
if (avaibility === Availability.Available && bestReady === null) {
|
|
146
148
|
bestReady = language;
|
|
147
149
|
break;
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
/>
|
|
19
19
|
<button class="radio-play-button" @click="playRadio">
|
|
20
20
|
<PlayIcon v-if="!playingRadio" :title="t('Play')" :size="40" />
|
|
21
|
-
<PodcastIsPlaying v-else/>
|
|
22
|
-
<div class="
|
|
21
|
+
<PodcastIsPlaying v-else />
|
|
22
|
+
<div class="mx-2">
|
|
23
23
|
{{ playText }}
|
|
24
24
|
</div>
|
|
25
25
|
</button>
|
|
@@ -84,4 +84,4 @@ function playRadio(): void {
|
|
|
84
84
|
padding: 0.2rem;
|
|
85
85
|
border: 0;
|
|
86
86
|
}
|
|
87
|
-
</style>
|
|
87
|
+
</style>
|
|
@@ -21,7 +21,12 @@
|
|
|
21
21
|
:href="sub.url"
|
|
22
22
|
:title="t('New window', {text: sub.title})"
|
|
23
23
|
>
|
|
24
|
-
<component
|
|
24
|
+
<component
|
|
25
|
+
:is="sub.icon"
|
|
26
|
+
:fill-color="fillColor(sub)"
|
|
27
|
+
:size="iconSize"
|
|
28
|
+
non-decorative
|
|
29
|
+
/>
|
|
25
30
|
</a>
|
|
26
31
|
</div>
|
|
27
32
|
<a
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="classic-button-group">
|
|
3
|
+
<button
|
|
4
|
+
v-for="option in options"
|
|
5
|
+
:key="option.value"
|
|
6
|
+
type="button"
|
|
7
|
+
class="btn"
|
|
8
|
+
:class="{ active: modelValue.includes(option.value) }"
|
|
9
|
+
@click="toggle(option.value)"
|
|
10
|
+
>
|
|
11
|
+
{{ option.label }}
|
|
12
|
+
</button>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
export interface ButtonGroupOption {
|
|
18
|
+
label: string;
|
|
19
|
+
value: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
options: ButtonGroupOption[];
|
|
24
|
+
modelValue: string[];
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{
|
|
28
|
+
'update:modelValue': [values: string[]];
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
function toggle(value: string): void {
|
|
32
|
+
const isSelected = props.modelValue.includes(value);
|
|
33
|
+
if (isSelected && props.modelValue.length <= 1) { return; }
|
|
34
|
+
const newValues = isSelected
|
|
35
|
+
? props.modelValue.filter(v => v !== value)
|
|
36
|
+
: [...props.modelValue, value];
|
|
37
|
+
emit('update:modelValue', newValues);
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<style lang="scss" scoped>
|
|
42
|
+
.classic-button-group {
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
border: 1px solid var(--octopus-border-default);
|
|
45
|
+
border-radius: var(--octopus-border-radius);
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
|
|
48
|
+
.btn {
|
|
49
|
+
border: none;
|
|
50
|
+
border-right: 1px solid var(--octopus-border-default);
|
|
51
|
+
border-radius: 0;
|
|
52
|
+
background-color: var(--octopus-secondary);
|
|
53
|
+
color: var(--octopus-gray-text);
|
|
54
|
+
font-size: 0.8rem;
|
|
55
|
+
transition: background-color 0.15s ease;
|
|
56
|
+
|
|
57
|
+
&:last-child {
|
|
58
|
+
border-right: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
&:hover:not(.active) {
|
|
62
|
+
background-color: var(--octopus-secondary-darker);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
&.active {
|
|
66
|
+
background-color: var(--octopus-primary);
|
|
67
|
+
color: white;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
@@ -60,7 +60,9 @@
|
|
|
60
60
|
}"
|
|
61
61
|
:disabled="isDisable || disabled"
|
|
62
62
|
:required="!canBeNull"
|
|
63
|
+
:aria-required="!canBeNull"
|
|
63
64
|
:autocomplete="autocompleteType"
|
|
65
|
+
@blur="emit('blur')"
|
|
64
66
|
>
|
|
65
67
|
<textarea
|
|
66
68
|
v-else-if="isTextarea"
|
|
@@ -129,7 +131,7 @@
|
|
|
129
131
|
<script setup lang="ts">
|
|
130
132
|
import AsteriskIcon from "vue-material-design-icons/Asterisk.vue";
|
|
131
133
|
import HelpCircleIcon from "vue-material-design-icons/HelpCircle.vue";
|
|
132
|
-
import { computed, defineAsyncComponent, onMounted, Ref, ref, useTemplateRef, watch, getCurrentInstance } from "vue";
|
|
134
|
+
import { computed, defineAsyncComponent, onMounted, onUnmounted, Ref, ref, useTemplateRef, watch, getCurrentInstance } from "vue";
|
|
133
135
|
import { useI18n } from "vue-i18n";
|
|
134
136
|
const ClassicPopover = defineAsyncComponent(
|
|
135
137
|
() => import("../misc/ClassicPopover.vue"),
|
|
@@ -187,7 +189,7 @@ const props = defineProps({
|
|
|
187
189
|
})
|
|
188
190
|
|
|
189
191
|
//Emits
|
|
190
|
-
const emit = defineEmits(["update:textInit", "update:errorVariable"]);
|
|
192
|
+
const emit = defineEmits(["update:textInit", "update:errorVariable", "blur"]);
|
|
191
193
|
|
|
192
194
|
//Data
|
|
193
195
|
const textValue : Ref<string | undefined>= ref(undefined);
|
|
@@ -262,7 +264,7 @@ onMounted(()=>{
|
|
|
262
264
|
|
|
263
265
|
if (props.isTextarea) {
|
|
264
266
|
// Delay a scroll back to the top of the text area
|
|
265
|
-
setTimeout(() => {
|
|
267
|
+
scrollTimer = setTimeout(() => {
|
|
266
268
|
const textArea = document.getElementById(computedInputId.value);
|
|
267
269
|
if (textArea) {
|
|
268
270
|
textArea.scrollTop = 0;
|
|
@@ -270,6 +272,11 @@ onMounted(()=>{
|
|
|
270
272
|
}, 100);
|
|
271
273
|
}
|
|
272
274
|
});
|
|
275
|
+
|
|
276
|
+
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
|
|
277
|
+
onUnmounted(() => {
|
|
278
|
+
clearTimeout(scrollTimer);
|
|
279
|
+
});
|
|
273
280
|
|
|
274
281
|
//Methods
|
|
275
282
|
function addEmojiSelected(emoji: string) {
|
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
:class="isColumn !== false ? 'd-flex flex-nowrap align-items-center' : 'me-2'"
|
|
19
19
|
>
|
|
20
20
|
<input
|
|
21
|
-
:id="
|
|
21
|
+
:id="computedId + option.value"
|
|
22
22
|
:checked="textInit === option.value"
|
|
23
23
|
type="radio"
|
|
24
|
-
:name="
|
|
24
|
+
:name="computedId"
|
|
25
25
|
:value="option.value"
|
|
26
26
|
:disabled="isDisabled"
|
|
27
27
|
@input="onChange($event.target.value)"
|
|
28
28
|
>
|
|
29
|
-
<label class="c-hand" :for="
|
|
29
|
+
<label class="c-hand" :for="computedId + option.value">
|
|
30
30
|
<slot :name="'label-' + option.value" v-bind="slotBindings(option)">{{ option.title }}</slot>
|
|
31
31
|
</label>
|
|
32
32
|
|
|
@@ -36,8 +36,10 @@
|
|
|
36
36
|
</template>
|
|
37
37
|
|
|
38
38
|
<script setup generic="T extends { title: string; value: string|undefined; }" lang="ts">
|
|
39
|
+
import { computed, getCurrentInstance } from 'vue';
|
|
40
|
+
|
|
39
41
|
//Props
|
|
40
|
-
const { textInit, isColumn = true } = defineProps<{
|
|
42
|
+
const { textInit, isColumn = true, idRadio } = defineProps<{
|
|
41
43
|
options: Array<T>;
|
|
42
44
|
textInit?: string;
|
|
43
45
|
idRadio?: string;
|
|
@@ -50,6 +52,15 @@ const emit = defineEmits<{
|
|
|
50
52
|
(e: 'update:textInit', value: string): void;
|
|
51
53
|
}>();
|
|
52
54
|
|
|
55
|
+
const uid = getCurrentInstance()?.uid;
|
|
56
|
+
const computedId = computed((): string => {
|
|
57
|
+
if (idRadio !== undefined) {
|
|
58
|
+
return idRadio;
|
|
59
|
+
} else {
|
|
60
|
+
return 'classic-radio-' + uid;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
53
64
|
//Methods
|
|
54
65
|
function onChange(value: string){
|
|
55
66
|
emit('update:textInit', value)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="avatar-container d-flex align-items-center justify-content-center"
|
|
4
|
+
:aria-hidden="!nonDecorative"
|
|
5
|
+
:style="containerStyle"
|
|
6
|
+
>
|
|
7
|
+
<img
|
|
8
|
+
v-if="imageUrl"
|
|
9
|
+
:alt="nonDecorative ? name : ''"
|
|
10
|
+
:width="size"
|
|
11
|
+
:height="size"
|
|
12
|
+
:src="useProxyImageUrl(imageUrl, '' + size)"
|
|
13
|
+
>
|
|
14
|
+
<template v-else>
|
|
15
|
+
{{ name.length > 0 ? name[0].toUpperCase() : '?' }}
|
|
16
|
+
</template>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import { computed, type StyleValue } from 'vue';
|
|
22
|
+
import { useImageProxy } from '../composable/useImageProxy';
|
|
23
|
+
import { colorFromString } from '../../helper/colorFromString';
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
26
|
+
/** Name of the element to display */
|
|
27
|
+
name: string;
|
|
28
|
+
/** URL to the image to be displayed */
|
|
29
|
+
imageUrl?: string;
|
|
30
|
+
/** Size of the avatar */
|
|
31
|
+
size?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Indicates that this element is not decorative.
|
|
34
|
+
* Use this when this element is the only thing to identify a platform.
|
|
35
|
+
* Will use accessibility options to hide this element if true.
|
|
36
|
+
*/
|
|
37
|
+
nonDecorative?: boolean;
|
|
38
|
+
}>(), {
|
|
39
|
+
imageUrl: undefined,
|
|
40
|
+
size: 24
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const { useProxyImageUrl } = useImageProxy();
|
|
44
|
+
|
|
45
|
+
const containerStyle = computed((): StyleValue => {
|
|
46
|
+
const size = `${props.size}px`;
|
|
47
|
+
return {
|
|
48
|
+
'background-color': colorFromString(props.name),
|
|
49
|
+
'font-size': `${props.size / 2}px`,
|
|
50
|
+
height: size,
|
|
51
|
+
width: size
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<style scoped lang="scss">
|
|
57
|
+
.avatar-container {
|
|
58
|
+
border-radius: 30%;
|
|
59
|
+
font-weight: 800;
|
|
60
|
+
color: black;
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
@@ -3,21 +3,24 @@
|
|
|
3
3
|
-->
|
|
4
4
|
<template>
|
|
5
5
|
<MessageModal
|
|
6
|
-
v-if="
|
|
7
|
-
:title="
|
|
8
|
-
:message="
|
|
9
|
-
:closeable="
|
|
10
|
-
@close="
|
|
6
|
+
v-if="currentNotification"
|
|
7
|
+
:title="currentNotification.title"
|
|
8
|
+
:message="currentNotification.message"
|
|
9
|
+
:closeable="currentNotification.closeable !== false"
|
|
10
|
+
@close="clearNotification"
|
|
11
11
|
/>
|
|
12
12
|
</template>
|
|
13
13
|
|
|
14
14
|
<script setup lang="ts">
|
|
15
|
+
import { storeToRefs } from 'pinia';
|
|
16
|
+
import { useNotificationStore } from '../../stores/NotificationStore';
|
|
15
17
|
import MessageModal from './modal/MessageModal.vue';
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
const {
|
|
20
|
+
clearNotification
|
|
21
|
+
} = useNotificationStore();
|
|
18
22
|
|
|
19
23
|
const {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} = useNotifications();
|
|
24
|
+
currentNotification,
|
|
25
|
+
} = storeToRefs(useNotificationStore());
|
|
23
26
|
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get a color from a string.
|
|
3
|
+
* Using the same string, the color will always be the same.
|
|
4
|
+
* @param str The string used for the color
|
|
5
|
+
* @param saturation Saturation of the color
|
|
6
|
+
* @param lightness Lightness of the color
|
|
7
|
+
* @returns A color
|
|
8
|
+
*/
|
|
9
|
+
export function colorFromString(str: string, saturation = 80, lightness = 70) {
|
|
10
|
+
let hash = 0;
|
|
11
|
+
str.split('').forEach(char => {
|
|
12
|
+
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
|
13
|
+
});
|
|
14
|
+
return `hsl(${(hash + 360) % 360}, ${saturation}%, ${lightness}%)`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { defineStore } from "pinia";
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
type NotificationType = 'success'|'info'|'warning'|'error';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Type for notifications
|
|
8
|
+
*/
|
|
9
|
+
interface Notification {
|
|
10
|
+
/** Optional title of the notification */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Message displayed by the notification */
|
|
13
|
+
message: string;
|
|
14
|
+
/** Type of notification, may affect display */
|
|
15
|
+
type: NotificationType;
|
|
16
|
+
/** When set to false, disallow manual closing of modal notification (default: true) */
|
|
17
|
+
closeable?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const useNotificationStore = defineStore('notifications', () => {
|
|
21
|
+
const notificationQueue = ref<Array<Notification>>([]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Add a notification to the queue
|
|
25
|
+
* @param notification The notification to add
|
|
26
|
+
*/
|
|
27
|
+
function addNotification(notification: Notification): void {
|
|
28
|
+
notificationQueue.value.push(notification);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Remove the current notification from the queue */
|
|
32
|
+
function clearNotification(): void {
|
|
33
|
+
const notif = currentNotification.value;
|
|
34
|
+
if (notif !== null) {
|
|
35
|
+
const idx = notificationQueue.value.indexOf(notif);
|
|
36
|
+
notificationQueue.value.splice(idx, 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The currently active notification */
|
|
41
|
+
const currentNotification = computed((): Notification|undefined => {
|
|
42
|
+
if (notificationQueue.value.length > 0) {
|
|
43
|
+
return notificationQueue.value[0];
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
addNotification,
|
|
51
|
+
currentNotification,
|
|
52
|
+
clearNotification
|
|
53
|
+
}
|
|
54
|
+
});
|
|
@@ -241,7 +241,7 @@ export const usePlayerStore = defineStore("PlayerStore", {
|
|
|
241
241
|
return;
|
|
242
242
|
}
|
|
243
243
|
if (param.canalId) {
|
|
244
|
-
this.playerRadio = { ...param,
|
|
244
|
+
this.playerRadio = { ...param, isInit: false };
|
|
245
245
|
this.playerCurrentChange = -param.canalId;
|
|
246
246
|
}
|
|
247
247
|
},
|
|
@@ -168,6 +168,12 @@ $utilities: map.merge(
|
|
|
168
168
|
class: flex,
|
|
169
169
|
values: wrap nowrap
|
|
170
170
|
),
|
|
171
|
+
"gap": (
|
|
172
|
+
responsive: true,
|
|
173
|
+
property: gap,
|
|
174
|
+
class: gap,
|
|
175
|
+
values: $spacers
|
|
176
|
+
),
|
|
171
177
|
"justify-content": (
|
|
172
178
|
responsive: true,
|
|
173
179
|
property: justify-content,
|
|
@@ -428,4 +434,4 @@ $utilities: map.merge(
|
|
|
428
434
|
@if meta.type-of($utility) == "map" {
|
|
429
435
|
@include generate-utility($utility, "");
|
|
430
436
|
}
|
|
431
|
-
}
|
|
437
|
+
}
|
|
@@ -312,6 +312,24 @@ describe('useRights', () => {
|
|
|
312
312
|
});
|
|
313
313
|
});
|
|
314
314
|
|
|
315
|
+
describe('Aggregator permissions', () => {
|
|
316
|
+
['canCreateAggregator', 'canEditAggregator', 'canDeleteAggregator'].forEach(method => {
|
|
317
|
+
describe(method, () => {
|
|
318
|
+
['ADMIN', 'ORGANISATION'].forEach(role => {
|
|
319
|
+
it(`allows ${role}`, async () => {
|
|
320
|
+
await setup([role]);
|
|
321
|
+
expect(useRights()[method as 'canCreateAggregator']()).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('denies unrelated roles', async () => {
|
|
326
|
+
await setup(['PRODUCTION']);
|
|
327
|
+
expect(useRights()[method as 'canCreateAggregator']()).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
315
333
|
describe('Other permissions', () => {
|
|
316
334
|
describe('canEditCodeInsertPlayer', () => {
|
|
317
335
|
['ADMIN', 'ORGANISATION'].forEach(role => {
|
|
@@ -19,19 +19,26 @@ vi.mock('@/api/podcastApi', () => ({
|
|
|
19
19
|
},
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
|
+
vi.mock('@/api/organisationApi', () => ({
|
|
23
|
+
organisationApi: {
|
|
24
|
+
getAttributes: vi.fn()
|
|
25
|
+
}
|
|
26
|
+
}));
|
|
27
|
+
|
|
22
28
|
vi.mock('@/helper/language', () => ({
|
|
23
29
|
getLanguage: vi.fn().mockReturnValue('fr'),
|
|
24
30
|
}));
|
|
25
31
|
|
|
26
32
|
import { transcriptionApi, type PodcastTranslationData } from '@/api/transcriptionApi';
|
|
27
33
|
import { podcastApi } from '@/api/podcastApi';
|
|
34
|
+
import { organisationApi } from '@/api/organisationApi';
|
|
28
35
|
import { getLanguage } from '@/helper/language';
|
|
29
36
|
|
|
30
37
|
function makeTranslationData(overrides: Partial<PodcastTranslationData> = {}): PodcastTranslationData {
|
|
31
38
|
return { podcastId: 1, nativeLanguage: 'fr', translations: [], ...overrides };
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
function makeEmission(
|
|
41
|
+
function makeEmission(): Emission {
|
|
35
42
|
return {
|
|
36
43
|
emissionId: 0,
|
|
37
44
|
name: '',
|
|
@@ -40,7 +47,6 @@ function makeEmission(orgConfig?: object): Emission {
|
|
|
40
47
|
id: 'test-org',
|
|
41
48
|
imageUrl: '',
|
|
42
49
|
name: 'Test Org',
|
|
43
|
-
attributes: orgConfig ? { 'translation-config': JSON.stringify(orgConfig) } : undefined,
|
|
44
50
|
},
|
|
45
51
|
beneficiaries: [],
|
|
46
52
|
rubriqueIds: [],
|
|
@@ -50,7 +56,17 @@ function makeEmission(orgConfig?: object): Emission {
|
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
function setOrgTranslationConfig(config: object) {
|
|
53
|
-
vi.mocked(podcastApi.get).mockResolvedValue({
|
|
59
|
+
vi.mocked(podcastApi.get).mockResolvedValue({
|
|
60
|
+
emission: makeEmission(),
|
|
61
|
+
organisation: { id: 1 }
|
|
62
|
+
} as never);
|
|
63
|
+
setOrgAttributes(config);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function setOrgAttributes(orgConfig: object) {
|
|
67
|
+
vi.mocked(organisationApi.getAttributes).mockResolvedValue({
|
|
68
|
+
'translation-config': JSON.stringify(orgConfig)
|
|
69
|
+
});
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
describe('useTranslation', () => {
|
|
@@ -97,6 +113,10 @@ describe('useTranslation', () => {
|
|
|
97
113
|
});
|
|
98
114
|
|
|
99
115
|
const translationData = { nativeLanguage: 'it', podcastId: 0, translations: [] };
|
|
116
|
+
setOrgTranslationConfig({
|
|
117
|
+
createTranslation: { en: CreateTranslation.ALWAYS },
|
|
118
|
+
otherLanguage: CreateTranslation.NEVER,
|
|
119
|
+
});
|
|
100
120
|
|
|
101
121
|
const result = await composable.getMostRelevantLanguage(translationData)
|
|
102
122
|
expect(result.ready).toBe('it');
|
|
@@ -111,6 +131,10 @@ describe('useTranslation', () => {
|
|
|
111
131
|
});
|
|
112
132
|
|
|
113
133
|
const translationData = { nativeLanguage: 'fr', podcastId: 0, translations: [] };
|
|
134
|
+
setOrgTranslationConfig({
|
|
135
|
+
createTranslation: { en: CreateTranslation.ALWAYS },
|
|
136
|
+
otherLanguage: CreateTranslation.NEVER,
|
|
137
|
+
});
|
|
114
138
|
|
|
115
139
|
const result = await composable.getMostRelevantLanguage(translationData)
|
|
116
140
|
expect(result.ready).toBe('fr');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import ClassicButtonGroup from '@/components/form/ClassicButtonGroup.vue';
|
|
2
|
+
import { mount } from '@tests/utils';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const options = [
|
|
6
|
+
{ label: 'Audio', value: 'PODCAST' },
|
|
7
|
+
{ label: 'Video', value: 'VIDEO' },
|
|
8
|
+
{ label: 'Live', value: 'LIVE' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('ClassicButtonGroup', () => {
|
|
12
|
+
it('renders one button per option', async () => {
|
|
13
|
+
const wrapper = await mount(ClassicButtonGroup, {
|
|
14
|
+
props: { options, modelValue: ['PODCAST'] },
|
|
15
|
+
});
|
|
16
|
+
expect(wrapper.findAll('button')).toHaveLength(3);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('applies active class to selected options only', async () => {
|
|
20
|
+
const wrapper = await mount(ClassicButtonGroup, {
|
|
21
|
+
props: { options, modelValue: ['PODCAST', 'LIVE'] },
|
|
22
|
+
});
|
|
23
|
+
const buttons = wrapper.findAll('button');
|
|
24
|
+
expect(buttons[0].classes()).toContain('active');
|
|
25
|
+
expect(buttons[1].classes()).not.toContain('active');
|
|
26
|
+
expect(buttons[2].classes()).toContain('active');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('emits update:modelValue with added value when clicking inactive button', async () => {
|
|
30
|
+
const wrapper = await mount(ClassicButtonGroup, {
|
|
31
|
+
props: { options, modelValue: ['PODCAST'] },
|
|
32
|
+
});
|
|
33
|
+
await wrapper.findAll('button')[1].trigger('click');
|
|
34
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['PODCAST', 'VIDEO']]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('emits update:modelValue without removed value when clicking active button (not last)', async () => {
|
|
38
|
+
const wrapper = await mount(ClassicButtonGroup, {
|
|
39
|
+
props: { options, modelValue: ['PODCAST', 'VIDEO'] },
|
|
40
|
+
});
|
|
41
|
+
await wrapper.findAll('button')[0].trigger('click');
|
|
42
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['VIDEO']]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not emit when clicking the only active button', async () => {
|
|
46
|
+
const wrapper = await mount(ClassicButtonGroup, {
|
|
47
|
+
props: { options, modelValue: ['PODCAST'] },
|
|
48
|
+
});
|
|
49
|
+
await wrapper.findAll('button')[0].trigger('click');
|
|
50
|
+
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import '@tests/mocks/i18n';
|
|
2
|
+
|
|
3
|
+
import ClassicInputText from '@/components/form/ClassicInputText.vue';
|
|
4
|
+
import { mount } from '@tests/utils';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
describe('ClassicInputText', () => {
|
|
8
|
+
describe('aria-required', () => {
|
|
9
|
+
it('sets aria-required to true when canBeNull is false (default)', async () => {
|
|
10
|
+
const wrapper = await mount(ClassicInputText, {
|
|
11
|
+
props: { canBeNull: false }
|
|
12
|
+
});
|
|
13
|
+
expect(wrapper.find('input').attributes('aria-required')).toBe('true');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sets aria-required to false when canBeNull is true', async () => {
|
|
17
|
+
const wrapper = await mount(ClassicInputText, {
|
|
18
|
+
props: { canBeNull: true }
|
|
19
|
+
});
|
|
20
|
+
expect(wrapper.find('input').attributes('aria-required')).toBe('false');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import ClassicAvatar from '@/components/misc/ClassicAvatar.vue';
|
|
2
|
+
import { mount } from '@tests/utils';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const IMAGE_URL = 'https://example.com/avatar.png';
|
|
6
|
+
|
|
7
|
+
describe('ClassicAvatar', () => {
|
|
8
|
+
const mountAvatar = async (props: Record<string, unknown> = {}) =>
|
|
9
|
+
mount(ClassicAvatar, { props: { name: 'Spotify', ...props } });
|
|
10
|
+
|
|
11
|
+
describe('image rendering', () => {
|
|
12
|
+
it('renders an img with correct src when imageUrl is provided', async () => {
|
|
13
|
+
const wrapper = await mountAvatar({ imageUrl: IMAGE_URL });
|
|
14
|
+
expect(wrapper.find('img').exists()).toBe(true);
|
|
15
|
+
expect(wrapper.find('img').attributes('src')).toContain('example.com');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('does not render an img without imageUrl', async () => {
|
|
19
|
+
const wrapper = await mountAvatar();
|
|
20
|
+
expect(wrapper.find('img').exists()).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('initial letter fallback', () => {
|
|
25
|
+
it('shows the first letter uppercased', async () => {
|
|
26
|
+
const wrapper = await mountAvatar({ name: 'spotify' });
|
|
27
|
+
expect(wrapper.text()).toBe('S');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('shows ? when name is empty', async () => {
|
|
31
|
+
const wrapper = await mountAvatar({ name: '' });
|
|
32
|
+
expect(wrapper.text()).toBe('?');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('container style', () => {
|
|
37
|
+
it('applies size-based dimensions and font-size from size prop', async () => {
|
|
38
|
+
const wrapper = await mountAvatar({ size: 48 });
|
|
39
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
40
|
+
expect(style.height).toBe('48px');
|
|
41
|
+
expect(style.width).toBe('48px');
|
|
42
|
+
expect(style.fontSize).toBe('24px');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('defaults to 24px size and applies a background-color', async () => {
|
|
46
|
+
const wrapper = await mountAvatar();
|
|
47
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
48
|
+
expect(style.height).toBe('24px');
|
|
49
|
+
expect(style.width).toBe('24px');
|
|
50
|
+
expect(style.fontSize).toBe('12px');
|
|
51
|
+
expect(style.backgroundColor).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('accessibility', () => {
|
|
56
|
+
it('is aria-hidden and has empty alt on img when decorative', async () => {
|
|
57
|
+
const wrapper = await mountAvatar({ imageUrl: IMAGE_URL });
|
|
58
|
+
expect(wrapper.attributes('aria-hidden')).toBe('true');
|
|
59
|
+
expect(wrapper.find('img').attributes('alt')).toBe('');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('is not aria-hidden and uses name as alt on img when nonDecorative', async () => {
|
|
63
|
+
const wrapper = await mountAvatar({ imageUrl: IMAGE_URL, nonDecorative: true });
|
|
64
|
+
expect(wrapper.attributes('aria-hidden')).not.toBe('true');
|
|
65
|
+
expect(wrapper.find('img').attributes('alt')).toBe('Spotify');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { ref } from "vue";
|
|
2
|
-
|
|
3
|
-
type NotificationType = 'success'|'info'|'warning'|'error';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Type for notifications
|
|
7
|
-
*/
|
|
8
|
-
interface Notification {
|
|
9
|
-
/** Optional title of the notification */
|
|
10
|
-
title?: string;
|
|
11
|
-
/** Message displayed by the notification */
|
|
12
|
-
message: string;
|
|
13
|
-
/** Type of notification, may affect display */
|
|
14
|
-
type: NotificationType;
|
|
15
|
-
/** When set to false, disallow manual closing of modal notification (default: true) */
|
|
16
|
-
closeable?: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** The notification currently displayed */
|
|
20
|
-
const notification = ref<Notification|null>(null);
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Composable used to manage notifications
|
|
24
|
-
*/
|
|
25
|
-
export const useNotifications = () => {
|
|
26
|
-
/**
|
|
27
|
-
* Add & display a notification
|
|
28
|
-
* @param newNotif The data of the new notification
|
|
29
|
-
*/
|
|
30
|
-
function addNotification(newNotif: Notification): void {
|
|
31
|
-
notification.value = { ...newNotif };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Remove all notifications
|
|
36
|
-
*/
|
|
37
|
-
function clearNotifications(): void {
|
|
38
|
-
notification.value = null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
/** The currently active notification */
|
|
43
|
-
notification,
|
|
44
|
-
|
|
45
|
-
/** Add a new notification to be displayed */
|
|
46
|
-
addNotification,
|
|
47
|
-
/** Remove all active notifications */
|
|
48
|
-
clearNotifications
|
|
49
|
-
}
|
|
50
|
-
};
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
export interface Aggregator {
|
|
2
|
-
aggregatorId: number;
|
|
3
|
-
createdBy: string;
|
|
4
|
-
image?: string;
|
|
5
|
-
matchers: Array<Matcher>;
|
|
6
|
-
name: string;
|
|
7
|
-
}
|
|
8
|
-
export interface Matcher {
|
|
9
|
-
ip?: string;
|
|
10
|
-
ipRange?: string;
|
|
11
|
-
matchDisplayname?: boolean;
|
|
12
|
-
matcherId?: number;
|
|
13
|
-
uaRegexp?: string;
|
|
14
|
-
}
|
|
15
|
-
export interface RuleRss {
|
|
16
|
-
ruleId: number;
|
|
17
|
-
aggregatorIds: Array<number>;
|
|
18
|
-
emissionId?: number;
|
|
19
|
-
organisationId?: string;
|
|
20
|
-
parameters: Array<ParamRss>;
|
|
21
|
-
}
|
|
22
|
-
export interface ParamRss {
|
|
23
|
-
forbid: boolean;
|
|
24
|
-
maxAge: string;
|
|
25
|
-
maxItem: number;
|
|
26
|
-
minAge: string;
|
|
27
|
-
parameterId: number;
|
|
28
|
-
}
|