@saooti/octopus-sdk 41.11.0 → 41.12.0-beta

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 CHANGED
@@ -1,5 +1,40 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 41.12.0 (En cours)
4
+
5
+ **Features**
6
+
7
+ - Export de `colorFromString` pour usage hors du sdk
8
+ - Ajout d'un composable `useSticky` pour déterminer quand un élément sticky se
9
+ déclenche
10
+ - Ajout de `@stylistic/eslint-plugin` et mise à jour des règles eslint
11
+ - Ajout d'un prop dans `ClassicPopover` pour modifier le z-index
12
+ - Ajout d'un slot `option` pour `ClassicSelect`
13
+ - Composants `Octopus`
14
+ - Ajout de `OctopusSelect`, une version moderne de `ClassicSelect`
15
+ - Mise en commun de code entre `OctopusSelect` & `OctopusMultiselect`
16
+ - Ajustement de `OctopusMultiselect` pour fonctionner correctement avec des
17
+ objets identiques avec références différentes
18
+ - Ajout de nouvelles classes utilitaires dans le css
19
+ - Ajout de nouvelles options d'apparence pour `ClassicButton`
20
+ - Changement propriété principale de `ClassicButtonGroup` pour plus de cohérence
21
+
22
+ **Fixes**
23
+
24
+ - Correction d'une exception dans `deepEquals` si la comparaison est faite entre
25
+ une variable primitive et un objet
26
+
27
+ **Misc**
28
+
29
+ - Mise à jour des règles eslint
30
+
31
+ ## 41.11.1 (17/06/2026)
32
+
33
+ **Fixes**
34
+
35
+ - **14257** - Correction lecture radio depuis orga sécurisée
36
+ - **14587** - Correction détection de langue pour les sous-titres
37
+
3
38
  ## 41.11.0 (05/06/2026)
4
39
 
5
40
  **Features**
package/eslint-config.mjs CHANGED
@@ -2,6 +2,7 @@ import eslint from '@eslint/js';
2
2
  import eslintPluginVue from 'eslint-plugin-vue';
3
3
  import globals from 'globals';
4
4
  import typescriptEslint from 'typescript-eslint';
5
+ import stylistic from '@stylistic/eslint-plugin';
5
6
 
6
7
  export default typescriptEslint.config(
7
8
  { ignores: ['*.d.ts', '**/coverage', '**/dist'] },
@@ -11,6 +12,9 @@ export default typescriptEslint.config(
11
12
  ...typescriptEslint.configs.recommended,
12
13
  ...eslintPluginVue.configs['flat/recommended'],
13
14
  ],
15
+ plugins: {
16
+ '@stylistic': stylistic,
17
+ },
14
18
  files: ['**/*.{ts,vue}'],
15
19
  languageOptions: {
16
20
  ecmaVersion: 'latest',
@@ -33,11 +37,15 @@ export default typescriptEslint.config(
33
37
  "vue/no-ref-as-operand": ['error'],
34
38
 
35
39
  // Indentation
40
+ "@stylistic/indent": ['warn', 4, { "SwitchCase": 0 }],
36
41
  "vue/html-indent": ['warn', 4],
37
42
  "vue/script-indent": ['warn', 4],
43
+ "@stylistic/no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
38
44
 
39
45
  // Number of attributes per line (increase because sometimes two is not a lot)
40
- "vue/max-attributes-per-line": ['warn', { singleline: 2 } ]
41
- },
46
+ "vue/max-attributes-per-line": ['warn', { singleline: 2 } ],
47
+
48
+ "@typescript-eslint/no-unused-vars": "warn"
49
+ }
42
50
  }
43
51
  );
package/index.ts CHANGED
@@ -59,7 +59,8 @@ export * from "./src/components/buttons/";
59
59
  // Form
60
60
  import ClassicButtonGroup from "./src/components/form/ClassicButtonGroup.vue";
61
61
  import OctopusMultiselect from "./src/components/form/OctopusMultiselect.vue";
62
- export { ClassicButtonGroup, OctopusMultiselect };
62
+ import OctopusSelect from "./src/components/form/OctopusSelect.vue";
63
+ export { ClassicButtonGroup, OctopusMultiselect, OctopusSelect };
63
64
  export type { ButtonGroupOption } from "./src/components/form/ClassicButtonGroup.vue";
64
65
 
65
66
  //Display
@@ -144,6 +145,7 @@ export { useSeoTitleUrl } from "./src/components/composable/route/useSeoTitleUrl
144
145
  export { useSeasonsManagement } from "./src/components/composable/useSeasonsManagement.ts";
145
146
  export { useTranslation } from "./src/components/composable/useTranslation.ts";
146
147
  export { useDayjs } from "./src/components/composable/useDayjs.ts";
148
+ export { useSticky } from "./src/components/composable/useSticky.ts";
147
149
 
148
150
  //helper
149
151
  import domHelper from "./src/helper/domHelper.ts";
@@ -155,6 +157,7 @@ import downloadHelper from "./src/helper/downloadHelper.ts";
155
157
  import displayHelper from "./src/helper/displayHelper.ts";
156
158
  import debounce from "./src/helper/debounceHelper.ts";
157
159
  import { deepEqual } from "./src/helper/equals.ts";
160
+ export { colorFromString } from './src/helper/colorFromString';
158
161
 
159
162
  //stores
160
163
  import {useVastStore} from "./src/stores/VastStore.ts";
@@ -175,7 +178,7 @@ export * from "./src/api";
175
178
  // Types
176
179
  export { type Emission, SeasonMode, emptyEmissionData } from "./src/stores/class/general/emission.ts";
177
180
  export { type Organisation, type OrganisationAttributes, emptyOrganisationData, emptyOrgaData } from "./src/stores/class/general/organisation.ts";
178
- export { type Podcast, type PodcastAvailability, PodcastType } from "./src/stores/class/general/podcast.ts";
181
+ export { type Podcast, type SimplifiedPodcast, type PodcastAvailability, PodcastType, podcastToSimplified } from "./src/stores/class/general/podcast.ts";
179
182
  export { type Playlist, type PlaylistRule } from "./src/stores/class/general/playlist.ts";
180
183
  export { type Annotations } from "./src/stores/class/general";
181
184
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saooti/octopus-sdk",
3
- "version": "41.11.0",
3
+ "version": "41.12.0-beta",
4
4
  "private": false,
5
5
  "description": "Javascript SDK for using octopus",
6
6
  "author": "Saooti",
@@ -61,6 +61,7 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@pinia/testing": "^1.0.3",
64
+ "@stylistic/eslint-plugin": "*",
64
65
  "@types/sockjs-client": "^1.5.4",
65
66
  "@types/webpack-env": "^1.18.8",
66
67
  "@vitejs/plugin-vue": "^5.2.4",
@@ -90,9 +91,11 @@
90
91
  "url": "git+https://github.com/saooti/octopus-sdk.git"
91
92
  },
92
93
  "peerDependencies": {
94
+ "@stylistic/eslint-plugin": "^5.10.0",
93
95
  "dayjs": "^1.11.0",
94
96
  "eslint-plugin-vue": "^10.9.1",
95
97
  "pinia": ">=2.3.0",
98
+ "typescript-eslint": "^8.47.0",
96
99
  "vue": "^3.5.0",
97
100
  "vue-i18n": "^11.1.0",
98
101
  "vue-router": "^4.6.0"
@@ -14,6 +14,19 @@ async function get(radioId: number): Promise<Canal> {
14
14
  });
15
15
  }
16
16
 
17
+ /**
18
+ * Retrieve all radio canal by organisation
19
+ * @param organisationId Id of the organisation
20
+ * @returns The list of canal data
21
+ */
22
+ async function getAll(organisationId: string): Promise<Array<Canal>> {
23
+ return await classicApi.fetchData<Array<Canal>>({
24
+ api: ModuleApi.RADIO,
25
+ path: "canal/orga/" + organisationId + "/"
26
+ });
27
+ }
28
+
17
29
  export const radioApi = {
18
- get
30
+ get,
31
+ getAll
19
32
  };
@@ -1,7 +1,7 @@
1
1
  <!-- A simple button with accessibility in mind -->
2
2
  <template>
3
3
  <button
4
- class="btn"
4
+ :class="classes"
5
5
  :aria-disabled="disabled"
6
6
  :title="title"
7
7
  :aria-label="title"
@@ -12,11 +12,17 @@
12
12
  </template>
13
13
 
14
14
  <script setup lang="ts">
15
+ import { computed } from 'vue';
16
+
15
17
  const props = defineProps<{
16
18
  /** Disable the button */
17
19
  disabled?: boolean;
18
20
  /** Title of the button */
19
21
  title?: string;
22
+ /** Primary style for button */
23
+ primary?: boolean;
24
+ /** Icon style for button */
25
+ icon?: boolean;
20
26
  }>();
21
27
 
22
28
  const emit = defineEmits<{
@@ -24,6 +30,17 @@ const emit = defineEmits<{
24
30
  (e: 'click'): void;
25
31
  }>();
26
32
 
33
+ const classes = computed((): Array<string> => {
34
+ const ary: Array<string> = ['btn', 'd-flex'];
35
+ if (props.primary) {
36
+ ary.push('btn-primary');
37
+ }
38
+ if (props.icon) {
39
+ ary.push('btn-icon');
40
+ }
41
+ return ary;
42
+ });
43
+
27
44
  function onClick(): void {
28
45
  if (!props.disabled) {
29
46
  emit('click');
@@ -0,0 +1,99 @@
1
+ import { computed, getCurrentInstance, nextTick, ref } from 'vue';
2
+ import { onClickOutside } from '@vueuse/core';
3
+ import { useI18n } from 'vue-i18n';
4
+
5
+ export interface OctopusDropdownProps<T> {
6
+ options: T[];
7
+ optionLabel: keyof T & string;
8
+ optionKey?: keyof T;
9
+ isDisabled?: boolean;
10
+ placeholder?: string;
11
+ }
12
+
13
+ export function useOctopusDropdown<T>(
14
+ props: OctopusDropdownProps<T>,
15
+ emitSearch: (query: string) => void,
16
+ idPrefix: string = 'octopus-dropdown'
17
+ ) {
18
+ const { t } = useI18n();
19
+ const instance = getCurrentInstance();
20
+
21
+ const searchQuery = ref('');
22
+ const isOpen = ref(false);
23
+ const isHovered = ref(false);
24
+ const containerRef = ref<HTMLElement | null>(null);
25
+ const inputRef = ref<HTMLInputElement | null>(null);
26
+
27
+ const computedId = computed(() => `${idPrefix}-${instance?.uid}`);
28
+
29
+ // vnode.props contains the raw parent-provided props before defineEmits strips them
30
+ // from attrs, so onSearch is present whenever the parent writes @search="...".
31
+ const hasSearchListener = computed(() => !!instance?.vnode.props?.onSearch);
32
+
33
+ const displayedOptions = computed((): T[] => {
34
+ if (hasSearchListener.value) {
35
+ return props.options;
36
+ }
37
+ const query = searchQuery.value.toLowerCase();
38
+ if (!query) {
39
+ return props.options;
40
+ }
41
+ return props.options.filter((option) =>
42
+ getLabel(option).toLowerCase().includes(query)
43
+ );
44
+ });
45
+
46
+ const inputPlaceholder = computed(() => props.placeholder ?? t('Search'));
47
+
48
+ function getLabel(option: T): string {
49
+ return option[props.optionLabel] as string;
50
+ }
51
+
52
+ function openDropdown(): void {
53
+ if (props.isDisabled) {
54
+ return;
55
+ }
56
+ isOpen.value = true;
57
+ nextTick(() => {
58
+ inputRef.value?.focus();
59
+ });
60
+ }
61
+
62
+ function closeDropdown(): void {
63
+ isOpen.value = false;
64
+ searchQuery.value = '';
65
+ }
66
+
67
+ function toggleDropdown(): void {
68
+ if (isOpen.value) {
69
+ closeDropdown();
70
+ } else {
71
+ openDropdown();
72
+ }
73
+ }
74
+
75
+ function handleInput(): void {
76
+ if (hasSearchListener.value) {
77
+ emitSearch(searchQuery.value);
78
+ }
79
+ }
80
+
81
+ onClickOutside(containerRef, closeDropdown);
82
+
83
+ return {
84
+ searchQuery,
85
+ isOpen,
86
+ isHovered,
87
+ containerRef,
88
+ inputRef,
89
+ computedId,
90
+ hasSearchListener,
91
+ displayedOptions,
92
+ inputPlaceholder,
93
+ getLabel,
94
+ openDropdown,
95
+ closeDropdown,
96
+ toggleDropdown,
97
+ handleInput,
98
+ };
99
+ }
@@ -24,12 +24,17 @@ export const usePlayerLive = (hlsReady: Ref<boolean>)=>{
24
24
  const apiStore = useApiStore();
25
25
  const authStore = useAuthStore();
26
26
 
27
- const needToAddToken = computed(() => {
28
- return authStore.authParam.accessToken && ("SECURED" === playerStore.playerLive?.organisation?.privacy || playerStore.playerRadio?.secured);
29
- });
27
+ function needToAddToken(url: string): boolean {
28
+ if (authStore.authParam.accessToken && ("SECURED" === playerStore.playerLive?.organisation?.privacy || playerStore.playerRadio?.secured)) {
29
+ const baseDomain = apiStore.frontendUrl.replace(/^https?:\/\//, '');
30
+ // Add token only if request is on same domain
31
+ return url.includes(baseDomain);
32
+ }
33
+ return false;
34
+ }
30
35
 
31
36
  function onPlay(): void {
32
- playerStore.playerChangeStatus(PlayerStatus.PAUSED ===playerStore.playerStatus);
37
+ playerStore.playerChangeStatus(PlayerStatus.PAUSED === playerStore.playerStatus);
33
38
  }
34
39
 
35
40
  function playRadio() {
@@ -77,7 +82,7 @@ export const usePlayerLive = (hlsReady: Ref<boolean>)=>{
77
82
  !isAndroid
78
83
  ) {
79
84
  let url = playerStore.playerHlsUrl;
80
- if(needToAddToken.value) {
85
+ if(needToAddToken(url)) {
81
86
  if (url.includes('?')) {
82
87
  url += "&access_token="+authStore.authParam.accessToken;
83
88
  } else {
@@ -123,8 +128,8 @@ export const usePlayerLive = (hlsReady: Ref<boolean>)=>{
123
128
  backBufferLength:10,
124
129
  maxBufferLength:60,
125
130
 
126
- xhrSetup: (xhr: XMLHttpRequest) => {
127
- if (needToAddToken.value) {
131
+ xhrSetup: (xhr: XMLHttpRequest, url: string) => {
132
+ if (needToAddToken(url)) {
128
133
  xhr.setRequestHeader("Authorization", "Bearer " +authStore.authParam.accessToken);
129
134
  }
130
135
  }
@@ -0,0 +1,45 @@
1
+ import { ref, toValue, onMounted, onBeforeUnmount, type Ref, type MaybeRef } from 'vue';
2
+
3
+ /**
4
+ * Detects whether a sticky element is currently stuck (fixed at its `top` offset)
5
+ * or in its natural DOM position.
6
+ *
7
+ * A 1px invisible sentinel element is inserted immediately before the sticky element.
8
+ * When that sentinel scrolls out of the IntersectionObserver's root viewport (adjusted
9
+ * by `topOffset`), the element is considered stuck.
10
+ *
11
+ * `topOffset` must match the element's CSS `top` value in pixels. It is read once at
12
+ * mount time. Pass a `Ref<number>` if the value is computed asynchronously (e.g. from
13
+ * a CSS variable set by JavaScript after first render).
14
+ */
15
+ export function useSticky(elementRef: Ref<HTMLElement | null>, topOffset: MaybeRef<number> = 0) {
16
+ const isStuck = ref(false);
17
+ let sentinel: HTMLElement | null = null;
18
+ let observer: IntersectionObserver | null = null;
19
+
20
+ onMounted(() => {
21
+ const el = elementRef.value;
22
+ if (!el) {
23
+ return;
24
+ }
25
+
26
+ const offset = toValue(topOffset);
27
+
28
+ sentinel = document.createElement('div');
29
+ sentinel.style.cssText = 'height:1px;pointer-events:none;';
30
+ el.parentNode!.insertBefore(sentinel, el);
31
+
32
+ observer = new IntersectionObserver(
33
+ ([entry]) => { isStuck.value = !entry.isIntersecting; },
34
+ { rootMargin: `-${offset}px 0px 0px 0px` }
35
+ );
36
+ observer.observe(sentinel);
37
+ });
38
+
39
+ onBeforeUnmount(() => {
40
+ observer?.disconnect();
41
+ sentinel?.remove();
42
+ });
43
+
44
+ return { isStuck };
45
+ }
@@ -122,17 +122,15 @@ export const useTranslation = () => {
122
122
  // languages defined on browser, then default, and finally native language
123
123
  const languages: string[] = [];
124
124
  [userLanguage, ...navigator.languages, DEFAULT_LANGUAGE, translationData.nativeLanguage].forEach(l => {
125
+ // For each language, if it is a variant (for example fr-CH), use
126
+ // only the base language
127
+ if (l.includes('-')) {
128
+ l = l.split('-')[0];
129
+ }
130
+
125
131
  if (!languages.includes(l)) {
126
132
  languages.push(l);
127
133
  }
128
- // For each language, if it is a variant (for example fr-CH), also
129
- // add the base language
130
- if (l.includes('-')) {
131
- const base = l.split('-')[0];
132
- if (!languages.includes(base)) {
133
- languages.push(base);
134
- }
135
- }
136
134
  });
137
135
 
138
136
  const podcast = await podcastApi.get(translationData.podcastId);
@@ -1,23 +1,19 @@
1
1
  <template>
2
- <ClassicMultiselect
3
- id="group-chooser"
4
- ref="selectGroup"
2
+ <OctopusMultiselect
3
+ :placeholder="placeholder ?? $t('Search - Emission groups placeholder')"
4
+ :selected="groups"
5
+ :options="allGroups"
6
+ :no-border="noBorder"
5
7
  option-label="name"
6
- :placeholder="$t('Search - Emission groups placeholder')"
7
- :max-element="maxElement"
8
- width="400px"
9
- in-modal
10
- :option-chosen="groups"
11
- multiple
12
- @on-search="onSearch"
13
- @selected="emitSelected"
8
+ option-key="groupId"
9
+ @update:selected="emitSelected"
14
10
  />
15
11
  </template>
16
12
 
17
13
  <script setup lang="ts">
18
- import { useTemplateRef } from "vue";
14
+ import { onMounted, ref } from "vue";
19
15
 
20
- import ClassicMultiselect from "../../form/ClassicMultiselect.vue";
16
+ import OctopusMultiselect from "../../form/OctopusMultiselect.vue";
21
17
  import { groupsApi, EmissionGroup } from "../../../api/groupsApi";
22
18
 
23
19
  //Props
@@ -26,29 +22,31 @@ const props = defineProps<{
26
22
  organisationId?: string|Array<string>;
27
23
  /** Currently selected groups */
28
24
  groups: Array<EmissionGroup>;
25
+ /** Change default placeholder */
26
+ placeholder?: string;
27
+ /** Disable borders */
28
+ noBorder?: boolean;
29
29
  }>();
30
30
 
31
31
  //Emits
32
- const emit = defineEmits(["update:groups"]);
32
+ const emit = defineEmits<{
33
+ (e: "update:groups", groups: Array<EmissionGroup>): void;
34
+ }>();
33
35
 
34
36
  //Data
35
- const maxElement = 50;
36
- const selectGroupRef = useTemplateRef('selectGroup');
37
+ const maxElement = 200;
38
+ const allGroups = ref<Array<EmissionGroup>>([]);
37
39
 
38
- //Methods
39
- async function onSearch(query?: string): Promise<void> {
40
+ onMounted(async() => {
40
41
  const response = await groupsApi.search({
41
42
  first: 0,
42
43
  size: maxElement,
43
- search: query,
44
44
  organisationIds: [props.organisationId].flat(),
45
45
  });
46
46
 
47
- selectGroupRef.value!.afterSearch(
48
- response.result.filter(g => g.emissionIds?.length ?? 0 > 0),
49
- response.count
50
- );
51
- }
47
+ // Only groups with emissions are available
48
+ allGroups.value = response.result.filter(g => g.emissionIds?.length ?? 0 > 0);
49
+ });
52
50
 
53
51
  function emitSelected(option: Array<EmissionGroup>) {
54
52
  emit("update:groups", option);
@@ -5,7 +5,7 @@
5
5
  :key="option.value"
6
6
  type="button"
7
7
  class="btn"
8
- :class="{ active: modelValue.includes(option.value) }"
8
+ :class="{ active: value.includes(option.value) }"
9
9
  @click="toggle(option.value)"
10
10
  >
11
11
  {{ option.label }}
@@ -13,28 +13,30 @@
13
13
  </div>
14
14
  </template>
15
15
 
16
- <script setup lang="ts">
17
- export interface ButtonGroupOption {
16
+ <script setup lang="ts" generic="T">
17
+ export interface ButtonGroupOption<T> {
18
18
  label: string;
19
- value: string;
19
+ value: T;
20
20
  }
21
21
 
22
22
  const props = defineProps<{
23
- options: ButtonGroupOption[];
24
- modelValue: string[];
23
+ /** Currently selected values */
24
+ value: T[];
25
+ /** Possible values */
26
+ options: ButtonGroupOption<T>[];
25
27
  }>();
26
28
 
27
29
  const emit = defineEmits<{
28
- 'update:modelValue': [values: string[]];
30
+ (e: 'update:value', values: T[]): void;
29
31
  }>();
30
32
 
31
- function toggle(value: string): void {
32
- const isSelected = props.modelValue.includes(value);
33
- if (isSelected && props.modelValue.length <= 1) { return; }
33
+ function toggle(value: T): void {
34
+ const isSelected = props.value.includes(value);
35
+ if (isSelected && props.value.length <= 1) { return; }
34
36
  const newValues = isSelected
35
- ? props.modelValue.filter(v => v !== value)
36
- : [...props.modelValue, value];
37
- emit('update:modelValue', newValues);
37
+ ? props.value.filter(v => v !== value)
38
+ : [...props.value, value];
39
+ emit('update:value', newValues);
38
40
  }
39
41
  </script>
40
42
 
@@ -17,7 +17,14 @@
17
17
  :required="displayRequired"
18
18
  @change="onChange($event.target.value)"
19
19
  >
20
- <option v-if="placeholder" value="" disabled selected>{{ placeholder }}</option>
20
+ <option
21
+ v-if="placeholder"
22
+ value=""
23
+ disabled
24
+ selected
25
+ >
26
+ {{ placeholder }}
27
+ </option>
21
28
  <option
22
29
  v-for="option in optionsOrder"
23
30
  :key="option.title"
@@ -25,7 +32,9 @@
25
32
  :data-selenium="'select-option-' + option.value"
26
33
  :style="option.fontFamily ? 'font-family:' + option.fontFamily : ''"
27
34
  >
28
- {{ option.title }}
35
+ <slot name="option" :option="option">
36
+ {{ option.title }}
37
+ </slot>
29
38
  </option>
30
39
  </select>
31
40
  </div>