diff --git a/front/src/App.vue b/front/src/App.vue
index bd85ec9a1..a596a66be 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -19,7 +19,8 @@ import Queue from '~/components/Queue.vue'
import Sidebar from '~/ui/components/Sidebar.vue'
import ShortcutsModal from '~/ui/modals/Shortcuts.vue'
import LanguagesModal from '~/ui/modals/Languages.vue'
-import UploadModal from '~/ui/modals/Upload.vue';
+import SearchModal from '~/ui/modals/Search.vue'
+import UploadModal from '~/ui/modals/Upload.vue'
// Fake content
onMounted(async () => {
@@ -90,6 +91,7 @@ store.dispatch('auth/fetchUser')
+
diff --git a/front/src/components/ui/Input.vue b/front/src/components/ui/Input.vue
index 47aa05f00..405ddf880 100644
--- a/front/src/components/ui/Input.vue
+++ b/front/src/components/ui/Input.vue
@@ -62,13 +62,13 @@ const model = defineModel({ required: true })
diff --git a/front/src/ui/components/Sidebar.vue b/front/src/ui/components/Sidebar.vue
index 405a06227..a97ea06a8 100644
--- a/front/src/ui/components/Sidebar.vue
+++ b/front/src/ui/components/Sidebar.vue
@@ -3,6 +3,7 @@ import { ref, onMounted, watch, computed } from 'vue'
import { useUploadsStore } from '../stores/upload'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
+import { useModal } from '~/ui/composables/useModal.ts'
import Logo from '~/components/Logo.vue'
import Input from '~/components/ui/Input.vue'
@@ -18,14 +19,13 @@ const isCollapsed = ref(true)
const route = useRoute()
watch(() => route.path, () => isCollapsed.value = true)
-const searchQuery = ref('')
-
// Hide the fake app when the real one is loaded
onMounted(() => {
document.getElementById('fake-app')?.remove()
})
const { t } = useI18n()
+const { value: searchParameter } = useModal('search')
const store = useStore()
const uploads = useUploadsStore()
@@ -91,17 +91,14 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index'
-
+
diff --git a/front/src/ui/components/UserMenu.vue b/front/src/ui/components/UserMenu.vue
index edc45183c..8ca05de8b 100644
--- a/front/src/ui/components/UserMenu.vue
+++ b/front/src/ui/components/UserMenu.vue
@@ -91,7 +91,7 @@ const labels = computed(() => ({
{{ labels.settings }}
-
+
{{ labels.language }}...
diff --git a/front/src/ui/composables/useModal.ts b/front/src/ui/composables/useModal.ts
index 0faa53cac..c6107a4c6 100644
--- a/front/src/ui/composables/useModal.ts
+++ b/front/src/ui/composables/useModal.ts
@@ -1,82 +1,117 @@
-import { computed } from 'vue'
-import { useRouter, type RouteLocationRaw } from 'vue-router'
+import { computed } from "vue";
+import { useRouter, type RouteLocationRaw, type LocationQuery } from "vue-router";
/**
* Bind a modal to a single query parameter in the URL (and vice versa)
*
* @param flag query parameter to appear in the `url`, corresponding with `isOpen`
+ * @param assignment a tuple of two functions:
+ * - from outside world to flag value (default: (_) => null)
+ * - from flag value to boolean (default: if flag value is null)
*
* This functionality completely independent from the `router` modules.
*/
-export function useModal(flag: string) {
- const router = useRouter()
- const query = computed(() => router?.currentRoute.value ? router.currentRoute.value.query : {})
+export const useModal = (
+ flag: string,
+ assignment: { on: (value : T | null) => string | null, isOn: (value: LocationQuery[string]) => boolean } =
+ { on: (_) => null, isOn: (value) => value === null }
+) => {
+ const router = useRouter();
+ const query = computed(() =>
+ router?.currentRoute.value ? router.currentRoute.value.query : {}
+ );
- /**
- * Visibility of this modal
- *
- * Use this function to bind a modal to a query parameter in the URL.
- * Using this helper function will not change the routes.
- *
- * For example, if the modal flag is 'upload':
- *
- * ```vue
- *
- * ...
- *
- * ```
- *
- * Url: `https://funkwhale.audio/?upload`
- *
- * More information on query flags: https://router.vuejs.org/api/type-aliases/LocationQueryValue.html
- *
- * Use `asAttribute` instead to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
- *
- * @param flag Identifier for the modal
- * @returns a `ref`
- */
- const isOpen = computed({
- get() {
- const newValue = flag in query.value && query.value[flag] === null
- // console.log("GET isOpen", flag, "in", query, newValue)
- return newValue
- },
- set(newValue: boolean) {
- // console.log("SET isOpen", query, "+", newValue, ", was", isOpen.value)
- router.push({ query: { ...query.value, [flag]: newValue ? null : undefined }}).catch((err) => {
- throw new Error(`Problem pushing route query: ${err}.`);
- });
- // console.log("DONE SET isOpen", query)
- }
- })
+ /**
+ * Visibility of this modal
+ *
+ * Use this function to bind a modal to a query parameter in the URL.
+ * Using this helper function will not change the routes.
+ *
+ * For example, if the modal flag is 'upload':
+ *
+ * ```vue
+ *
+ * ...
+ *
+ * ```
+ *
+ * Url: `https://funkwhale.audio/?upload`
+ *
+ * More information on query flags: https://router.vuejs.org/api/type-aliases/LocationQueryValue.html
+ *
+ * Use `asAttribute` instead to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
+ *
+ * @param flag Identifier for the modal
+ * @returns a `ref`
+ */
+ const isOpen = computed({
+ get() {
+ return flag in query.value && assignment.isOn(query.value[flag]);
+ },
+ set(newValue: boolean) {
+ router
+ .push({
+ query: {
+ ...query.value,
+ [flag]: newValue ? assignment.on(null) : undefined,
+ },
+ })
+ .catch((err) => {
+ throw new Error(`Problem pushing route query: ${err}.`);
+ });
+ },
+ });
- /**
- *
- * @param flag Identifier for the modal
- * @returns a `RouteLocationRaw` object to use in the `to` prop of a RouterLink component
- */
- const to: RouteLocationRaw = {
- query: { ...query.value, [flag]: null }
- }
+ /**
+ * Current value of the flag in the URL, normalized to a string.
+ *
+ * Note that both `null` and `undefined` are coerced to an empty string, and arrays are joined with a space.
+ *
+ * Use in inputs.
+ */
+ const value = computed({
+ get() {
+ const flagValue = flag in query.value ? query.value[flag] : '';
+ return typeof flagValue === 'string' ? flagValue : flagValue === null ? '' : flagValue.join(' ');
+ },
+ set(newValue: string) {
+ router
+ .push({
+ query: {
+ ...query.value,
+ [flag]: newValue,
+ },
+ })
+ .catch((err) => {
+ throw new Error(`Problem pushing route query: ${err}.`);
+ });
+ },
+ })
- /**
- * Use this function to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
- *
- * @param flag Identifier for the modal
- * @returns a `ref`
- */
- const asAttribute = computed(() =>
- isOpen.value || undefined
- )
+ /**
+ *
+ * @param flag Identifier for the modal
+ * @returns a `RouteLocationRaw` object to use in the `to` prop of a RouterLink component
+ */
+ const to: RouteLocationRaw = {
+ query: { ...query.value, [flag]: null },
+ };
- /**
- * Toggle the visibility of this modal
- *
- *
- * @param flag Identifier for the modal
- */
- const toggle = () =>
- isOpen.value = !isOpen.value
+ /**
+ * Use this function to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
+ *
+ * @param flag Identifier for the modal
+ * @returns a `ref`
+ */
+ const asAttribute = computed(() => isOpen.value || undefined);
- return { isOpen, to, asAttribute, toggle }
+ /**
+ * Toggle the visibility of this modal
+ *
+ *
+ * @param flag Identifier for the modal
+ */
+ const toggle = () => (isOpen.value = !isOpen.value);
+
+ return { value, isOpen, to, asAttribute, toggle };
}
diff --git a/front/src/ui/modals/Languages.vue b/front/src/ui/modals/Language.vue
similarity index 95%
rename from front/src/ui/modals/Languages.vue
rename to front/src/ui/modals/Language.vue
index 9ff5e1eb3..5ce2b030e 100644
--- a/front/src/ui/modals/Languages.vue
+++ b/front/src/ui/modals/Language.vue
@@ -10,7 +10,7 @@ import Layout from '~/components/ui/Layout.vue'
const { t, locale } = useI18n()
-const isOpen = useModal('languages').isOpen
+const isOpen = useModal('language').isOpen
diff --git a/front/src/ui/modals/Search.vue b/front/src/ui/modals/Search.vue
new file mode 100644
index 000000000..716aa5ae8
--- /dev/null
+++ b/front/src/ui/modals/Search.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/ui/modals/Upload.vue b/front/src/ui/modals/Upload.vue
index 058c264e2..08cce88fe 100644
--- a/front/src/ui/modals/Upload.vue
+++ b/front/src/ui/modals/Upload.vue
@@ -4,6 +4,8 @@ import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut';
+import { useModal } from '~/ui/composables/useModal.ts'
+
import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
@@ -23,21 +25,9 @@ import LibraryWidget from '~/components/federation/LibraryWidget.vue'
const { t } = useI18n()
const store = useStore()
-const modalName = 'upload'
+onKeyboardShortcut('u', () => useModal('upload').toggle())
-const isOpen = computed({
- get() {
- return store.state.ui.modalsOpen.has(modalName);
- },
- set(value) {
- if (value===false) {
- state.value = init()
- }
- store.commit('ui/setModal', [modalName, value])
- }
-})
-
-onKeyboardShortcut('u', () => store.commit('ui/toggleModal', modalName))
+const isOpen = useModal('upload').isOpen
type UploadDestination = 'channel' | 'library' | 'podcast'