Refactor(front): [WIP] use store to cache and distribute user state

Co-Authored-By: ArneBo <arne@ecobasa.org>
Co-Authored-By: Flupsi <upsiflu@gmail.com>
Co-Authored-By: jon r <jon@allmende.io>
This commit is contained in:
jon r 2025-04-18 11:14:02 +02:00
parent eadaa72c27
commit 22b4c5128b
5 changed files with 204 additions and 79 deletions

View File

@ -3,6 +3,6 @@ import { RootState } from '~/store'
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
interface ComponentCustomProperties { interface ComponentCustomProperties {
$store: Store<RootState> store: Store<RootState>
} }
} }

View File

@ -1,5 +1,6 @@
import type { Module } from 'vuex' import type { Module } from 'vuex'
import type { RootState } from '~/store/index' import type { RootState } from '~/store/index'
import type { components } from '~/generated/types'
import axios from 'axios' import axios from 'axios'
import { merge } from 'lodash-es' import { merge } from 'lodash-es'
@ -11,84 +12,80 @@ export interface State {
frontSettings: FrontendSettings frontSettings: FrontendSettings
instanceUrl?: string instanceUrl?: string
knownInstances: string[] knownInstances: string[]
nodeinfo: NodeInfo | null nodeinfo: components['schemas']['NodeInfo21'] | null
settings: Settings settings: Settings
} }
type TotalCount = { // export interface NodeInfo {
total: number // version: string;
} // software: {
// name: string;
export interface NodeInfo { // version: string;
version: string; // }
software: { // protocols: any[];
name: string; // services?: {
version: string; // inbound?: string[];
} // outbound?: string[];
protocols: any[]; // }
services?: { // openRegistrations: boolean;
inbound?: string[]; // usage: {
outbound?: string[]; // users: {
} // total: number;
openRegistrations: boolean; // activeHalfyear: number;
usage: { // activeMonth: number;
users: { // }
total: number; // }
activeHalfyear: number; // metadata: {
activeMonth: number; // actorId: string
} // 'private': boolean
} // shortDescription: string
metadata: { // longDescription: string
actorId: string // rules: string
'private': boolean // contactEmail: string
shortDescription: string // terms: string
longDescription: string // nodeName: string
rules: string // banner: string
contactEmail: string // defaultUploadQuota: number
terms: string // content: {
nodeName: string // federationEnabled: boolean
banner: string // anonymousCanListen: boolean
defaultUploadQuota: number // local: {
content: { // tracks?: TotalCount
federationEnabled: boolean // artists?: TotalCount
anonymousCanListen: boolean // albums?: TotalCount
local: { // hoursOfContent?: number }
tracks?: TotalCount // }
artists?: TotalCount // topMusicCategories: []
albums?: TotalCount // topPodcastCategories: []
hoursOfContent?: number } // federation: {
} // followedInstances: number
topMusicCategories: [] // followingInstances: number
topPodcastCategories: [] // }
federation: { // supportedUploadExtensions: string[]
followedInstances: number // allowList: {
followingInstances: number // enabled: boolean
} // domains: string[]
supportedUploadExtensions: string[] // }
allowList: { // reportTypes: {
enabled: boolean // 'type': string
domains: string[] // label: string
} // anonymous: boolean
reportTypes: { // }[]
'type': string // funkwhaleSupportMessageEnabled: boolean
label: string // instanceSupportMessage: string
anonymous: boolean // endpoints: {
}[] // knownNodes?: string
funkwhaleSupportMessageEnabled: boolean // channels?: string
instanceSupportMessage: string // libraries?: string
endpoints: { // }
knownNodes?: string // usage: {
channels?: string // favorites: { tracks: TotalCount }
libraries?: string // listenings: TotalCount
} // downloads: TotalCount
usage: { // }
favorites: { tracks: TotalCount } // features:[]
listenings: TotalCount // }
downloads: TotalCount // }
}
features:[]
}
}
interface FrontendSettings { interface FrontendSettings {
defaultServerUrl: string defaultServerUrl: string

View File

@ -31,7 +31,9 @@ export interface CurrentRadio {
objectId: ObjectId | null 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() const logger = useLogger()

View File

@ -1,6 +1,8 @@
import type { Module } from 'vuex' import type { Module } from 'vuex'
import type { RootState } from '~/store/index' import type { RootState } from '~/store/index'
import type { SUPPORTED_LOCALES } from '~/init/locale' import type { SUPPORTED_LOCALES } from '~/init/locale'
import type { Channel } from '~/types'
import type { components } from '~/generated/types'
import axios from 'axios' import axios from 'axios'
import moment from 'moment' import moment from 'moment'
@ -32,6 +34,8 @@ interface Message {
type NotificationsKey = 'inbox' | 'pendingReviewEdits' | 'pendingReviewReports' | 'pendingReviewRequests' type NotificationsKey = 'inbox' | 'pendingReviewEdits' | 'pendingReviewReports' | 'pendingReviewRequests'
type IsOpen = 'true' | 'undefined'
export interface State { export interface State {
currentLanguage: 'en_US' | keyof typeof SUPPORTED_LOCALES currentLanguage: 'en_US' | keyof typeof SUPPORTED_LOCALES
selectedLanguage: boolean selectedLanguage: boolean
@ -47,9 +51,13 @@ export interface State {
width: number width: number
} }
pageTitle: null pageTitle: null
modalsOpen: Set<string>
notifications: Record<NotificationsKey, number> notifications: Record<NotificationsKey, number>
websocketEventsHandlers: Record<WebSocketEventName, WebSocketHandlers> websocketEventsHandlers: Record<WebSocketEventName, WebSocketHandlers>
preselectedChannelForUpload: null | [Channel, 'podcast' | 'music']
tags: null | components['schemas']['Tag'][]
} }
const logger = useLogger() const logger = useLogger()
@ -85,7 +93,11 @@ const store: Module<State, RootState> = {
'user_request.created': {}, 'user_request.created': {},
Listen: {} Listen: {}
}, },
pageTitle: null pageTitle: null,
modalsOpen: new Set([]),
preselectedChannelForUpload: null,
tags: null
}, },
getters: { getters: {
showInstanceSupportMessage: (state, getters, rootState) => { showInstanceSupportMessage: (state, getters, rootState) => {
@ -149,7 +161,9 @@ const store: Module<State, RootState> = {
} else { } else {
return 'large' return 'large'
} }
} },
modalIsOpen: (state, key) =>
state.modalsOpen.has(key)
}, },
mutations: { mutations: {
addWebsocketEventHandler: (state, { eventName, id, handler }: { eventName: WebSocketEventName, id: string, handler: (event: any) => void}) => { addWebsocketEventHandler: (state, { eventName, id, handler }: { eventName: WebSocketEventName, id: string, handler: (event: any) => void}) => {
@ -190,6 +204,20 @@ const store: Module<State, RootState> = {
removeMessage (state, key) { removeMessage (state, key) {
state.messages.splice(state.messages.findIndex(message => message.key === key), 1) 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 }) { notifications (state, { type, count }: { type: NotificationsKey, count: number }) {
state.notifications[type] = count state.notifications[type] = count
}, },
@ -205,6 +233,9 @@ const store: Module<State, RootState> = {
}, },
window: (state, value) => { window: (state, value) => {
state.window = value state.window = value
},
tags: (state, value) => {
state.tags = value
} }
}, },
actions: { actions: {

View File

@ -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<string[], string[]>) => {
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<number>(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<paths['/api/v2/tags/']['get']['responses']['200']['content']['application/json']>(
'/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
// ...