From 22b4c5128bdb194e48865d0bb65303a0710d2833 Mon Sep 17 00:00:00 2001 From: jon r Date: Fri, 18 Apr 2025 11:14:02 +0200 Subject: [PATCH] Refactor(front): [WIP] use store to cache and distribute user state Co-Authored-By: ArneBo Co-Authored-By: Flupsi Co-Authored-By: jon r --- front/src/shims-vuex.d.ts | 2 +- front/src/store/instance.ts | 147 ++++++++++++++-------------- front/src/store/radios.ts | 4 +- front/src/store/ui.ts | 35 ++++++- front/src/ui/composables/useTags.ts | 95 ++++++++++++++++++ 5 files changed, 204 insertions(+), 79 deletions(-) create mode 100644 front/src/ui/composables/useTags.ts diff --git a/front/src/shims-vuex.d.ts b/front/src/shims-vuex.d.ts index 9658d05c7..0af9d7fcf 100644 --- a/front/src/shims-vuex.d.ts +++ b/front/src/shims-vuex.d.ts @@ -3,6 +3,6 @@ import { RootState } from '~/store' declare module '@vue/runtime-core' { interface ComponentCustomProperties { - $store: Store + store: Store } } diff --git a/front/src/store/instance.ts b/front/src/store/instance.ts index aa803daea..8ebd740b0 100644 --- a/front/src/store/instance.ts +++ b/front/src/store/instance.ts @@ -1,5 +1,6 @@ import type { Module } from 'vuex' import type { RootState } from '~/store/index' +import type { components } from '~/generated/types' import axios from 'axios' import { merge } from 'lodash-es' @@ -11,84 +12,80 @@ export interface State { frontSettings: FrontendSettings instanceUrl?: string knownInstances: string[] - nodeinfo: NodeInfo | null + nodeinfo: components['schemas']['NodeInfo21'] | null settings: Settings } -type TotalCount = { - total: number -} - -export interface NodeInfo { - version: string; - software: { - name: string; - version: string; - } - protocols: any[]; - services?: { - inbound?: string[]; - outbound?: string[]; - } - openRegistrations: boolean; - usage: { - users: { - total: number; - activeHalfyear: number; - activeMonth: number; - } - } - metadata: { - actorId: string - 'private': boolean - shortDescription: string - longDescription: string - rules: string - contactEmail: string - terms: string - nodeName: string - banner: string - defaultUploadQuota: number - content: { - federationEnabled: boolean - anonymousCanListen: boolean - local: { - tracks?: TotalCount - artists?: TotalCount - albums?: TotalCount - hoursOfContent?: number } - } - topMusicCategories: [] - topPodcastCategories: [] - federation: { - followedInstances: number - followingInstances: number - } - supportedUploadExtensions: string[] - allowList: { - enabled: boolean - domains: string[] - } - reportTypes: { - 'type': string - label: string - anonymous: boolean - }[] - funkwhaleSupportMessageEnabled: boolean - instanceSupportMessage: string - endpoints: { - knownNodes?: string - channels?: string - libraries?: string - } - usage: { - favorites: { tracks: TotalCount } - listenings: TotalCount - downloads: TotalCount - } - features:[] - } -} +// export interface NodeInfo { +// version: string; +// software: { +// name: string; +// version: string; +// } +// protocols: any[]; +// services?: { +// inbound?: string[]; +// outbound?: string[]; +// } +// openRegistrations: boolean; +// usage: { +// users: { +// total: number; +// activeHalfyear: number; +// activeMonth: number; +// } +// } +// metadata: { +// actorId: string +// 'private': boolean +// shortDescription: string +// longDescription: string +// rules: string +// contactEmail: string +// terms: string +// nodeName: string +// banner: string +// defaultUploadQuota: number +// content: { +// federationEnabled: boolean +// anonymousCanListen: boolean +// local: { +// tracks?: TotalCount +// artists?: TotalCount +// albums?: TotalCount +// hoursOfContent?: number } +// } +// topMusicCategories: [] +// topPodcastCategories: [] +// federation: { +// followedInstances: number +// followingInstances: number +// } +// supportedUploadExtensions: string[] +// allowList: { +// enabled: boolean +// domains: string[] +// } +// reportTypes: { +// 'type': string +// label: string +// anonymous: boolean +// }[] +// funkwhaleSupportMessageEnabled: boolean +// instanceSupportMessage: string +// endpoints: { +// knownNodes?: string +// channels?: string +// libraries?: string +// } +// usage: { +// favorites: { tracks: TotalCount } +// listenings: TotalCount +// downloads: TotalCount +// } +// features:[] +// } +// } interface FrontendSettings { defaultServerUrl: string diff --git a/front/src/store/radios.ts b/front/src/store/radios.ts index 4d92a206e..5fdae471b 100644 --- a/front/src/store/radios.ts +++ b/front/src/store/radios.ts @@ -31,7 +31,9 @@ export interface CurrentRadio { objectId: ObjectId | null } -export type RadioConfig = { type: 'tag', names: string[] } | { type: 'artist' | 'playlist', ids: string[] } +export type RadioConfig + = { type: 'tag', names: string[] } + | { type: 'artist' | 'playlist', ids: string[] } const logger = useLogger() diff --git a/front/src/store/ui.ts b/front/src/store/ui.ts index f7ad005df..38eb21701 100644 --- a/front/src/store/ui.ts +++ b/front/src/store/ui.ts @@ -1,6 +1,8 @@ import type { Module } from 'vuex' import type { RootState } from '~/store/index' import type { SUPPORTED_LOCALES } from '~/init/locale' +import type { Channel } from '~/types' +import type { components } from '~/generated/types' import axios from 'axios' import moment from 'moment' @@ -32,6 +34,8 @@ interface Message { type NotificationsKey = 'inbox' | 'pendingReviewEdits' | 'pendingReviewReports' | 'pendingReviewRequests' +type IsOpen = 'true' | 'undefined' + export interface State { currentLanguage: 'en_US' | keyof typeof SUPPORTED_LOCALES selectedLanguage: boolean @@ -47,9 +51,13 @@ export interface State { width: number } pageTitle: null + modalsOpen: Set notifications: Record websocketEventsHandlers: Record + preselectedChannelForUpload: null | [Channel, 'podcast' | 'music'] + + tags: null | components['schemas']['Tag'][] } const logger = useLogger() @@ -85,7 +93,11 @@ const store: Module = { 'user_request.created': {}, Listen: {} }, - pageTitle: null + pageTitle: null, + modalsOpen: new Set([]), + preselectedChannelForUpload: null, + + tags: null }, getters: { showInstanceSupportMessage: (state, getters, rootState) => { @@ -149,7 +161,9 @@ const store: Module = { } else { return 'large' } - } + }, + modalIsOpen: (state, key) => + state.modalsOpen.has(key) }, mutations: { addWebsocketEventHandler: (state, { eventName, id, handler }: { eventName: WebSocketEventName, id: string, handler: (event: any) => void}) => { @@ -190,6 +204,20 @@ const store: Module = { removeMessage (state, key) { state.messages.splice(state.messages.findIndex(message => message.key === key), 1) }, + + addModal (state, key) { + state.modalsOpen.add(key) + }, + removeModal (state, key) { + state.modalsOpen.delete(key) + }, + toggleModal (state, key) { + state.modalsOpen.has(key) ? state.modalsOpen.delete(key) : state.modalsOpen.add(key) + }, + setModal (state, [key, isOpen]:[string, IsOpen]) { + isOpen ? state.modalsOpen.add(key) : state.modalsOpen.delete(key) + }, + notifications (state, { type, count }: { type: NotificationsKey, count: number }) { state.notifications[type] = count }, @@ -205,6 +233,9 @@ const store: Module = { }, window: (state, value) => { state.window = value + }, + tags: (state, value) => { + state.tags = value } }, actions: { diff --git a/front/src/ui/composables/useTags.ts b/front/src/ui/composables/useTags.ts new file mode 100644 index 000000000..fa56abef9 --- /dev/null +++ b/front/src/ui/composables/useTags.ts @@ -0,0 +1,95 @@ +import type { paths } from '~/generated/types.ts' + +import { computed, ref, type Ref } from 'vue' +import { useStore } from '~/store' +import axios from 'axios' + +export type Item = { type: 'custom' | 'preset', label: string } +export type Model = { currents: Item[], others?: Item[] } + +/** + * Load and cache all tags. + * - Two-way binding with store (any change to the store will be reflected in `others`) + * - Two-way binding with ref + * @param currents Selected tags + * @returns an object with `currents` and `others`, ready to be used inside + */ +export const useTags = (currents: Ref) => { + const store = useStore() + + // Ignore quick successive fetch triggers + const ignorePeriod = 500 + + // Wait between consecutiv fetches + const waitInterval = 3000 + + // Wait after changing a tag before re-fetching + const refetchInterval = 6000 + + // Number of tags to load on one page + const maxTags = 100000 + + const lastFetched = ref(0) + + const fetch = async () => { + // console.log('FETCH TAGS') + // Ignore subsequent fetch commands triggered in quick succession + if (lastFetched.value + ignorePeriod > Date.now()) + return + + // Always wait some milliseconds before re-fetching + if (lastFetched.value + waitInterval > Date.now()) { + window.setTimeout(fetch, lastFetched.value + waitInterval - Date.now()) + return + } + + const response = await axios.get( + '/tags', + { params: { page: 1, page_size: maxTags } } + ) + + // console.log('TAGS RESPONSE.data.results', response.data.results) + store.commit('ui/tags', response.data.results) + } + + fetch(); + + /** + * @returns v-model for `Pills` component + */ + return computed({ + get () { + // console.log("GET TAGS") + return ({ + // Get `currents` from parameter + currents: currents.value.map(tag => ({ + label: tag, + type: (store.state.ui.tags || []).some(({ name }) => tag === name) ? 'preset' : 'custom' + } as const)), + + // Get `others` from cache + others: (store.state.ui.tags || []) + .filter(({ name }) => !currents.value.includes(name)) + .map(({ name }) => ({ + label: name, + type: 'preset' + } as const)) + })}, + + set (model) { + // console.log('SET TAGS', response.data.results) + + // Set parameter `currents` from `model.currents` + currents.value = model.currents.map(({ label }) => label) + + // Set runtime-only options from `model.others` and `model.current` + // TODO: Broadcast new custom tags so that other pills components can use them + + // Re-fetch after each new setting + window.setTimeout(fetch, refetchInterval) + } +}) +} + +// alternative ways to generate tags +// ...