{{ query ? `No tokens match “${query}”.` : 'No access tokens yet. Generate one to get started.' }}
+
+ {{ query ? `No tokens match “${query}”.` : 'No access tokens yet. Generate one to get started.' }}
+
-
-
diff --git a/reposilite-frontend/src/helpers/toast.js b/reposilite-frontend/src/helpers/toast.js
index 40133b263..91fd2ae9e 100644
--- a/reposilite-frontend/src/helpers/toast.js
+++ b/reposilite-frontend/src/helpers/toast.js
@@ -1,20 +1,45 @@
import { createToast } from 'mosha-vue-toastify'
+/*
+ * Bottom right, and in one place. The library defaults to the top right corner, which is
+ * exactly where the header keeps the account name and the logout button: while a toast was
+ * up, logging out was not clickable. Callers used to reach for `createToast` directly and
+ * pick a corner each, so a single login attempt could put its success message in one corner
+ * and its failure in another.
+ */
+const options = (type) => ({ type, position: 'bottom-right' })
+
+/**
+ * The readable part of a failed request.
+ *
+ * Callers reached into `error.response.status` and `error.response.data.message` directly,
+ * which works for a server that answered and throws for one that did not. The throw
+ * happened inside a `.catch`, so it took the handler with it: exactly when the connection
+ * was refused or the host was unreachable, the user was told nothing at all. That is the
+ * most common failure there is, and it was the one case with no message.
+ */
+const errorMessage = (error) =>
+ error?.response?.data?.message ||
+ (error?.response?.status ? `Request failed with status ${error.response.status}` : null) ||
+ error?.message ||
+ `${error}`
+
const createInfoToast = (message) =>
- createToast(message, { type: 'info' })
+ createToast(message, options('info'))
const createSuccessToast = (message) =>
- createToast(message, { type: 'success' })
+ createToast(message, options('success'))
const createWarningToast = (message) =>
- createToast(message, { type: 'warning' })
+ createToast(message, options('warning'))
const createErrorToast = (message) =>
- createToast(message, { type: 'danger' })
+ createToast(message, options('danger'))
export {
createInfoToast,
createSuccessToast,
createWarningToast,
- createErrorToast
-}
\ No newline at end of file
+ createErrorToast,
+ errorMessage
+}
diff --git a/reposilite-frontend/src/pages/IndexPage.vue b/reposilite-frontend/src/pages/IndexPage.vue
index 11fc1bc9c..38a2ea680 100644
--- a/reposilite-frontend/src/pages/IndexPage.vue
+++ b/reposilite-frontend/src/pages/IndexPage.vue
@@ -19,6 +19,7 @@ import { computed, ref, watchEffect, defineAsyncComponent } from 'vue'
import { useSession } from '../store/session'
import useQualifier from '../store/qualifier'
import DefaultHeader from '../components/header/DefaultHeader.vue'
+import HeaderHero from '../components/header/HeaderHero.vue'
import FileBrowserView from '../components/browser/FileBrowserView.vue'
import {Tabs, Tab, TabPanels, TabPanel} from 'vue3-tabs'
import { property } from '../helpers/vue-extensions'
@@ -93,13 +94,17 @@ const selectHomepage = () =>
-
+
+
+
+
+
-
+
diff --git a/reposilite-frontend/src/store/configuration.js b/reposilite-frontend/src/store/configuration.js
index 582609b93..7c03da2da 100644
--- a/reposilite-frontend/src/store/configuration.js
+++ b/reposilite-frontend/src/store/configuration.js
@@ -1,8 +1,8 @@
import {computed, markRaw, ref, toRaw} from 'vue'
import { useSession } from './session'
-import { createToast } from 'mosha-vue-toastify'
+import { createSuccessToast, createErrorToast, errorMessage } from '../helpers/toast'
import { createAjv } from '@jsonforms/core'
-import { vanillaRenderers } from '@dzikoysk/vue-vanilla'
+import { defaultStyles, mergeStyles, vanillaRenderers } from '@dzikoysk/vue-vanilla'
import { default as ObjectRenderer, tester as objectTester } from '../components/renderers/ObjectRenderer.vue'
import { default as AllOfRenderer, tester as allOfTester } from '../components/renderers/AllOfRenderer.vue'
import { default as ArrayListRenderer, tester as arrayListTester } from '../components/renderers/ArrayListRenderer.vue'
@@ -26,8 +26,8 @@ const fetchConfiguration = () => {
.then(configurationResponse => configurations.value[domain] = configurationResponse.data)))
)
.then(() => selectedDomain.value = domains.value[0])
- .then(() => createToast('Configuration loaded', { type: 'success' }))
- .catch(error => createToast(`${error || ''}`, { type: 'danger' }))
+ .then(() => createSuccessToast('Configuration loaded'))
+ .catch(error => createErrorToast(`Cannot load configuration: ${errorMessage(error)}`))
}
const updateConfiguration = () =>
@@ -36,8 +36,8 @@ const updateConfiguration = () =>
.then(() => client.value.settings.fetch(domain))
.then(response => configurations.value[domain] = response.data)
))
- .then(() => createToast('Configuration updated', { type: 'success' }))
- .catch(error => createToast(`${error || ''}`, { type: 'danger' }))
+ .then(() => createSuccessToast('Configuration updated'))
+ .catch(error => createErrorToast(`Cannot update configuration: ${errorMessage(error)}`))
const renderers = markRaw([
{ tester: arrayListTester, renderer: ArrayListRenderer },
@@ -56,6 +56,51 @@ const renderers = markRaw([
...vanillaRenderers,
])
+/*
+ * The renderers of @dzikoysk/vue-vanilla take every class name they emit from a styles
+ * object that SettingsView provides, which is the only supported way to style them from
+ * the outside. Utilities written here therefore replace what used to be a stylesheet
+ * reaching into the library's DOM with selectors like `.control .input`.
+ *
+ * mergeStyles appends to the defaults instead of replacing them, so each element keeps its
+ * original class name as well. The few rules that cannot live here still select on those
+ * names, and so does the vue3-tabs markup wrapped around the array and one-of renderers.
+ */
+const configurationStyles = mergeStyles(defaultStyles, {
+ control: {
+ description: 'pl-[0.45em] text-sm italic',
+ error: 'text-red-500 px-2 font-bold',
+ input: 'mx-2 rounded',
+ select: 'mx-2 rounded pr-8 text-sm h-9 px-4 text-black',
+ wrapper: 'flex py-2'
+ },
+ verticalLayout: {
+ root: 'flex flex-col flex-wrap py-4 h-full gap-4'
+ },
+ group: {
+ root: 'flex flex-col flex-wrap py-4 h-full gap-4'
+ },
+ arrayList: {
+ /* No padding, because a fieldset carries the list and its legend holds the add button. */
+ root: 'flex flex-col flex-wrap h-full gap-4 p-0',
+ legend: 'flex flex-row-reverse gap-2 w-full mb-0',
+ addButton: 'rounded-full h-6 w-6 leading-6 bg-blue-700 ml-auto text-white z-1',
+ label: 'font-bold',
+ /* Spelled out because the defaults leave this one unset, unlike every other key here. */
+ description: 'description pl-[0.45em] text-sm italic',
+ noData: 'p-4 bg-gray-200 dark:bg-gray-900 italic rounded-md',
+ itemToolbar: 'flex flex-row items-baseline relative',
+ itemLabel: 'mr-auto hidden',
+ /* The tab bar already moves entries around, so the two arrows stay out of the way. */
+ itemMoveUp: 'hidden p-2',
+ itemMoveDown: 'hidden p-2',
+ itemDelete: 'absolute right-0 top-2 p-2'
+ },
+ oneOf: {
+ root: 'one-of-container h-full flex flex-col'
+ }
+})
+
const configurationValidator = computed(() => {
const ajv = createAjv({
useDefaults: true,
@@ -81,6 +126,7 @@ export function useConfiguration() {
fetchConfiguration,
updateConfiguration,
renderers,
+ configurationStyles,
configurationValidator,
domains,
configurations,
diff --git a/reposilite-frontend/src/store/console/connection.js b/reposilite-frontend/src/store/console/connection.js
index 297b2af42..f9fc62cbb 100644
--- a/reposilite-frontend/src/store/console/connection.js
+++ b/reposilite-frontend/src/store/console/connection.js
@@ -16,16 +16,26 @@
import { ref } from "vue"
import { createURL } from '../client'
-import { EventSource as Eventsource } from 'extended-eventsource';
-import { useSession } from "../session.js";
+import { EventSource as Eventsource } from 'extended-eventsource'
+import { useSession } from "../session.js"
const { client } = useSession()
const connection = ref()
const command = ref("")
+/**
+ * Where the stream stands, as something a template can render. `readyState` alone cannot
+ * do that job: it is not reactive, and it collapses "never tried" and "gave up" into the
+ * same value, which are the two cases a reader most needs told apart.
+ *
+ * One of 'idle', 'connecting', 'open', 'closed' or 'error'.
+ */
+const status = ref('idle')
+const failure = ref(null)
+
export default function useConsole() {
- const consoleAddress = createURL("/api/console/log");
+ const consoleAddress = createURL("/api/console/log")
const isConnected = () => {
// using built-in EventSource for readystate constants
@@ -34,8 +44,12 @@ export default function useConsole() {
}
const close = () => {
- if (isConnected())
- connection.value.close()
+ // Not gated on isConnected: a stream still opening has a readyState of CONNECTING and
+ // would slip through, which is how leaving the tab mid-handshake used to leave the
+ // request running.
+ connection.value?.close()
+ connection.value = undefined
+ status.value = 'closed'
}
const history = ref([''])
@@ -78,11 +92,17 @@ export default function useConsole() {
const onClose = ref()
const connect = (token) => {
+ status.value = 'connecting'
+ failure.value = null
+
try {
connection.value = new Eventsource(consoleAddress, {
headers: {
Authorization: `xBasic ${btoa(`${token.name}:${token.secret}`)}`
},
+ // No automatic retry, on purpose. A stream that fails because the server is
+ // struggling is the worst moment to start reconnecting in a loop. The view offers
+ // a button instead, so the retry happens when somebody decides it should.
disableRetry: true
})
@@ -90,9 +110,10 @@ export default function useConsole() {
// this is needed to stop an error from appearing in console when
// switching/refreshing the page without closing the connection
window.onbeforeunload = function () {
- close();
- };
+ close()
+ }
+ status.value = 'open'
onOpen?.value()
}
@@ -102,13 +123,20 @@ export default function useConsole() {
})
connection.value.onerror = (error) => {
+ status.value = 'error'
+ failure.value = describe(error)
onError?.value(error)
}
- connection.value.onclose = () =>
+ connection.value.onclose = () => {
+ // An error already says more than a close does, so it keeps the field.
+ if (status.value !== 'error') status.value = 'closed'
onClose?.value()
+ }
} catch (error) {
+ status.value = 'error'
+ failure.value = describe(error)
onError?.value(error)
}
}
@@ -117,6 +145,8 @@ export default function useConsole() {
connection,
connect,
close,
+ status,
+ failure,
onOpen,
onMessage,
onError,
@@ -128,3 +158,18 @@ export default function useConsole() {
isConnected
}
}
+
+/**
+ * An EventSource error event carries no reason, so there is nothing to quote back. Say what
+ * is actually known instead of inventing a cause: whether the browser is offline is the one
+ * distinction that changes what the reader should do next.
+ */
+function describe(error) {
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
+ return 'This browser is offline.'
+ }
+ if (error instanceof Error && error.message) {
+ return error.message
+ }
+ return 'The server closed the stream or could not be reached.'
+}
diff --git a/reposilite-frontend/src/store/tokens.js b/reposilite-frontend/src/store/tokens.js
index 12a2f207a..45045b31d 100644
--- a/reposilite-frontend/src/store/tokens.js
+++ b/reposilite-frontend/src/store/tokens.js
@@ -16,14 +16,11 @@
import { ref } from 'vue'
import { useSession } from './session'
-import { createSuccessToast, createErrorToast } from '../helpers/toast'
+import { createSuccessToast, createErrorToast, errorMessage } from '../helpers/toast'
const { client } = useSession()
const tokens = ref([])
-const errorMessage = (error) =>
- error?.response?.data?.message || `${error}`
-
const tokenIsManager = (token) =>
(token.permissions || []).some(permission => permission.identifier === 'access-token:manager')
diff --git a/reposilite-frontend/src/style.css b/reposilite-frontend/src/style.css
index b8a88f9ab..d0acae159 100644
--- a/reposilite-frontend/src/style.css
+++ b/reposilite-frontend/src/style.css
@@ -106,15 +106,6 @@
--color-green-500: #22c55e;
--color-yellow-500: #eab308;
-
- /*
- * Windi shipped a near-black ramp called `dark`, which the mobile tab dropdown and the
- * console divider use. It has no Tailwind counterpart, so the two shades still in use
- * are carried over verbatim. The name reads oddly next to the `dark:` variant; that is
- * one more reason it goes away with this block.
- */
- --color-dark-300: #2d2d2d;
- --color-dark-600: #1c1c1e;
}
/*