feat : init Central project — Symfony 8 + Nuxt 4 + Docker starter
Same architecture as Lesstime: API Platform 4, JWT auth, @malio/layer-ui, PostgreSQL 16, Docker Compose (ports 8083/3003/5436), dark mode theme. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
|
||||
13
frontend/app.vue
Normal file
13
frontend/app.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { load } = useAppVersion()
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
248
frontend/assets/css/dark.css
Normal file
248
frontend/assets/css/dark.css
Normal file
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Dark theme overrides
|
||||
* Automatically applied when <html class="dark"> is set.
|
||||
* Overrides existing Tailwind utilities so components need zero changes.
|
||||
*/
|
||||
|
||||
/* ── Backgrounds ── */
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: #1e1f2b !important;
|
||||
}
|
||||
|
||||
.dark .bg-tertiary-500 {
|
||||
background-color: #262838 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-50 {
|
||||
background-color: #262838 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-100 {
|
||||
background-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-200 {
|
||||
background-color: #363952 !important;
|
||||
}
|
||||
|
||||
/* ── Hover backgrounds ── */
|
||||
|
||||
.dark .hover\:bg-neutral-50:hover {
|
||||
background-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-100:hover {
|
||||
background-color: #363952 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-200:hover {
|
||||
background-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-300:hover {
|
||||
background-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:shadow-md:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3) !important;
|
||||
}
|
||||
|
||||
/* ── Text ── */
|
||||
|
||||
.dark .text-neutral-900 {
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-800 {
|
||||
color: #d4d4d8 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-700 {
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-600 {
|
||||
color: #8b8b9a !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-500 {
|
||||
color: #71717a !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-400 {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-300 {
|
||||
color: #52525b !important;
|
||||
}
|
||||
|
||||
/* ── Hover text ── */
|
||||
|
||||
.dark .hover\:text-neutral-700:hover {
|
||||
color: #d4d4d8 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:text-neutral-600:hover {
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* ── Borders ── */
|
||||
|
||||
.dark .border-neutral-200 {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .border-neutral-100 {
|
||||
border-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .border-neutral-300 {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:border-neutral-300:hover {
|
||||
border-color: #4a4d64 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:border-neutral-400:hover {
|
||||
border-color: #4a4d64 !important;
|
||||
}
|
||||
|
||||
/* ── Ring ── */
|
||||
|
||||
.dark .ring-black\/5 {
|
||||
--tw-ring-color: rgb(255 255 255 / 0.05) !important;
|
||||
}
|
||||
|
||||
/* ── Specific component overrides ── */
|
||||
|
||||
/* Modal header bg */
|
||||
.dark .bg-neutral-50\/80 {
|
||||
background-color: rgb(38 40 56 / 0.8) !important;
|
||||
}
|
||||
|
||||
/* Sidebar collapse button */
|
||||
.dark .shadow-sm {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2) !important;
|
||||
}
|
||||
|
||||
/* User dropdown */
|
||||
.dark .shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3) !important;
|
||||
}
|
||||
|
||||
/* Forms: inputs, selects, textareas */
|
||||
.dark input:not([type="checkbox"]):not([type="radio"]),
|
||||
.dark textarea,
|
||||
.dark select {
|
||||
background-color: #1e1f2b !important;
|
||||
color: #e5e5e5 !important;
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark input:not([type="checkbox"]):not([type="radio"])::placeholder,
|
||||
.dark textarea::placeholder {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
.dark input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
.dark textarea:focus,
|
||||
.dark select:focus {
|
||||
border-color: #222783 !important;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.dark label {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* ── Malio Layer UI components ── */
|
||||
|
||||
/* MalioSelect: floating label has hardcoded background: white */
|
||||
.dark .floating-label {
|
||||
background: #1e1f2b !important;
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: text-black used for selected value and options */
|
||||
.dark .text-black {
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .text-black\/60 {
|
||||
color: #71717a !important;
|
||||
}
|
||||
|
||||
.dark .text-black\/40 {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: border-black used when option is selected */
|
||||
.dark .border-black {
|
||||
border-color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: border-m-muted default border */
|
||||
.dark .border-m-muted {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: dropdown option hover background */
|
||||
.dark .bg-m-muted\/10 {
|
||||
background-color: rgb(160 174 192 / 0.15) !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: dropdown shadow */
|
||||
.dark .shadow-2xl {
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
/* Checkbox/radio hardcoded black borders */
|
||||
.dark .inp-cbx + .cbx svg {
|
||||
stroke: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .inp-cbx + .cbx {
|
||||
border-color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* Red/colored backgrounds for buttons */
|
||||
.dark .bg-red-50 {
|
||||
background-color: rgb(127 29 29 / 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-red-100:hover {
|
||||
background-color: rgb(127 29 29 / 0.3) !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-50 {
|
||||
background-color: rgb(30 58 138 / 0.2) !important;
|
||||
}
|
||||
|
||||
/* Datetime/date input color-scheme for dark mode */
|
||||
.dark input[type="datetime-local"],
|
||||
.dark input[type="date"],
|
||||
.dark input[type="time"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.dark ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #1e1f2b;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #3a3d54;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4d64;
|
||||
}
|
||||
56
frontend/components/ui/AppTopNav.vue
Normal file
56
frontend/components/ui/AppTopNav.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:menu"
|
||||
aria-label="Menu"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||
@click="ui.openMobileSidebar()"
|
||||
/>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">Central</h1>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<MalioButtonIcon
|
||||
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
||||
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||
@click="ui.toggleDarkMode()"
|
||||
/>
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
|
||||
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
defineProps<{
|
||||
user?: UserData
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
52
frontend/components/ui/SidebarLink.vue
Normal file
52
frontend/components/ui/SidebarLink.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="group/link relative flex items-center transition-colors hover:text-primary-500"
|
||||
:class="linkClasses"
|
||||
:active-class="exact ? '' : activeClass"
|
||||
:exact-active-class="exact ? activeClass : ''"
|
||||
>
|
||||
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
|
||||
<span
|
||||
v-if="!collapsed"
|
||||
class="self-baseline whitespace-nowrap overflow-hidden transition-opacity duration-300"
|
||||
:class="sub ? 'text-sm' : 'text-md'"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<div
|
||||
v-if="collapsed"
|
||||
class="pointer-events-none absolute left-full z-50 ml-2 rounded-md bg-neutral-800 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/link:pointer-events-auto group-hover/link:opacity-100 whitespace-nowrap"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
to: string
|
||||
icon: string
|
||||
label: string
|
||||
collapsed: boolean
|
||||
sub?: boolean
|
||||
exact?: boolean
|
||||
}>()
|
||||
|
||||
const activeClass = computed(() => {
|
||||
if (props.collapsed) {
|
||||
return '!text-primary-500 bg-primary-500/10'
|
||||
}
|
||||
return '!text-primary-500 bg-tertiary-500'
|
||||
})
|
||||
|
||||
const linkClasses = computed(() => {
|
||||
if (props.collapsed) {
|
||||
return 'justify-center w-10 h-10 mx-auto my-1 p-2 rounded-lg text-neutral-600 hover:text-primary-500 hover:bg-primary-500/10'
|
||||
}
|
||||
if (props.sub) {
|
||||
return 'gap-3 px-4 py-2 pl-12 text-sm font-semibold text-neutral-700'
|
||||
}
|
||||
return 'gap-3 px-4 py-3 text-md font-semibold text-neutral-700'
|
||||
})
|
||||
</script>
|
||||
217
frontend/composables/useApi.ts
Normal file
217
frontend/composables/useApi.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { FetchOptions } from 'ofetch'
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
export type BlobResponse = {
|
||||
data: Blob
|
||||
headers: Headers
|
||||
}
|
||||
|
||||
export type ApiClient = {
|
||||
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise<BlobResponse>
|
||||
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
delete<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
}
|
||||
|
||||
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||
FetchOptions<ResponseType> & {
|
||||
toast?: boolean
|
||||
toastOn401?: boolean
|
||||
toastTitle?: string
|
||||
toastErrorMessage?: string
|
||||
toastSuccessMessage?: string
|
||||
toastErrorKey?: string
|
||||
toastSuccessKey?: string
|
||||
}
|
||||
|
||||
let isHandlingUnauthorized = false
|
||||
|
||||
export function useApi(): ApiClient {
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const nuxtApp = useNuxtApp()
|
||||
const i18n = nuxtApp.$i18n as
|
||||
| {
|
||||
t: (key: string) => string
|
||||
te?: (key: string) => boolean
|
||||
}
|
||||
| undefined
|
||||
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
|
||||
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
|
||||
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string) ||
|
||||
(record.detail as string) ||
|
||||
(record.message as string) ||
|
||||
(record.error as string) ||
|
||||
(record.title as string) ||
|
||||
(record['hydra:title'] as string) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
|
||||
const methodErrorKeys: Record<string, string> = {
|
||||
GET: 'errors.http.get',
|
||||
POST: 'errors.http.post',
|
||||
PUT: 'errors.http.put',
|
||||
PATCH: 'errors.http.patch',
|
||||
DELETE: 'errors.http.delete'
|
||||
}
|
||||
|
||||
const client = $fetch.create({
|
||||
baseURL,
|
||||
retry: 0,
|
||||
credentials: 'include',
|
||||
onResponse({ options, response }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.status && response.status >= 400) {
|
||||
return
|
||||
}
|
||||
|
||||
const successKey = apiOptions?.toastSuccessKey
|
||||
const successMessage =
|
||||
apiOptions?.toastSuccessMessage ||
|
||||
(successKey ? (te(successKey) ? t(successKey) : successKey) : '')
|
||||
|
||||
if (successMessage) {
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: successMessage
|
||||
})
|
||||
}
|
||||
},
|
||||
async onResponseError({ response, error, options }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (response?.status === 401) {
|
||||
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
||||
const isLoginCheck = requestUrl.includes('/login_check')
|
||||
const isLogout = requestUrl.includes('/logout')
|
||||
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
|
||||
|
||||
if (shouldToast401) {
|
||||
const errorKey = apiOptions?.toastErrorKey
|
||||
const errorMessage =
|
||||
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
|
||||
const extractedMessage = extractErrorMessage(error, response?._data)
|
||||
const message =
|
||||
apiOptions?.toastErrorMessage ||
|
||||
errorMessage ||
|
||||
extractedMessage ||
|
||||
'Une erreur est survenue.'
|
||||
|
||||
toast.error({
|
||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
if (!isLoginCheck && !isLogout) {
|
||||
if (!isHandlingUnauthorized) {
|
||||
isHandlingUnauthorized = true
|
||||
auth.clearSession()
|
||||
const route = useRoute()
|
||||
if (route.path !== '/login') {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
isHandlingUnauthorized = false
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const method =
|
||||
typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET'
|
||||
const defaultKey = methodErrorKeys[method]
|
||||
const defaultMessage =
|
||||
defaultKey && te(defaultKey) ? t(defaultKey) : ''
|
||||
const errorKey = apiOptions?.toastErrorKey
|
||||
const errorMessage =
|
||||
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
|
||||
const extractedMessage = extractErrorMessage(error, response?._data)
|
||||
const message =
|
||||
apiOptions?.toastErrorMessage ||
|
||||
errorMessage ||
|
||||
defaultMessage ||
|
||||
extractedMessage ||
|
||||
'Une erreur est survenue.'
|
||||
|
||||
toast.error({
|
||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
||||
message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function request<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
options: ApiFetchOptions<'json'> = {}
|
||||
) {
|
||||
const needsJsonBody = method === 'POST' || method === 'PUT'
|
||||
const needsMergePatch = method === 'PATCH'
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
|
||||
if (!isFormData) {
|
||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/merge-patch+json')
|
||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
}
|
||||
|
||||
return client<T>(url, { ...options, method, headers })
|
||||
}
|
||||
|
||||
return {
|
||||
get<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('GET', url, { ...options, query })
|
||||
},
|
||||
getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) {
|
||||
return client
|
||||
.raw(url, { ...options, method: 'GET', query, responseType: 'blob' })
|
||||
.then((res) => ({ data: res._data as Blob, headers: res.headers }))
|
||||
},
|
||||
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('POST', url, { ...options, body })
|
||||
},
|
||||
put<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('PUT', url, { ...options, body })
|
||||
},
|
||||
patch<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('PATCH', url, { ...options, body })
|
||||
},
|
||||
delete<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('DELETE', url, { ...options, query })
|
||||
}
|
||||
}
|
||||
}
|
||||
17
frontend/composables/useAppVersion.ts
Normal file
17
frontend/composables/useAppVersion.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function useAppVersion() {
|
||||
const api = useApi()
|
||||
const version = useState<string | null>('app-version', () => null)
|
||||
|
||||
async function load(): Promise<string | null> {
|
||||
if (version.value) {
|
||||
return version.value
|
||||
}
|
||||
const response = await api.get<{ version: string }>('version', {}, {
|
||||
toast: false
|
||||
})
|
||||
version.value = response.version
|
||||
return version.value
|
||||
}
|
||||
|
||||
return { version, load }
|
||||
}
|
||||
4
frontend/i18n/i18n.config.ts
Normal file
4
frontend/i18n/i18n.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default defineI18nConfig(() => ({
|
||||
legacy: false,
|
||||
locale: 'fr',
|
||||
}))
|
||||
25
frontend/i18n/locales/fr.json
Normal file
25
frontend/i18n/locales/fr.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"errors": {
|
||||
"http": {
|
||||
"get": "Impossible de récupérer les données.",
|
||||
"post": "Impossible de créer la ressource.",
|
||||
"put": "Impossible de mettre à jour la ressource.",
|
||||
"patch": "Impossible de mettre à jour la ressource.",
|
||||
"delete": "Impossible de supprimer la ressource."
|
||||
},
|
||||
"auth": {
|
||||
"login": "Identifiants invalides.",
|
||||
"logout": "Impossible de se déconnecter.",
|
||||
"session": "Session expirée"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"auth": {
|
||||
"login": "Connexion réussie.",
|
||||
"logout": "Déconnexion réussie."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord"
|
||||
}
|
||||
}
|
||||
7
frontend/layouts/auth.vue
Normal file
7
frontend/layouts/auth.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
|
||||
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
112
frontend/layouts/default.vue
Normal file
112
frontend/layouts/default.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="[
|
||||
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
|
||||
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
|
||||
<img
|
||||
v-if="!sidebarIsCollapsed"
|
||||
src="/malio.png"
|
||||
alt="Logo"
|
||||
class="w-auto"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/LOGO_CARRE.png"
|
||||
alt="Logo"
|
||||
class="w-[46px] h-[55px]"
|
||||
/>
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||
<SidebarLink
|
||||
to="/"
|
||||
icon="mdi:view-dashboard-outline"
|
||||
label="Tableau de bord"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Collapse toggle button centered vertically on the sidebar edge -->
|
||||
<button
|
||||
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
|
||||
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
<Icon
|
||||
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||
size="18"
|
||||
/>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
const sidebarIsCollapsed = computed(() => {
|
||||
if (ui.sidebarOpen) return false
|
||||
return ui.sidebarCollapsed
|
||||
})
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
|
||||
useHead({
|
||||
titleTemplate: (title) => title || 'Central',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
16
frontend/middleware/auth.global.ts
Normal file
16
frontend/middleware/auth.global.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const isLogin = to.path === '/login'
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!isLogin && !auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
59
frontend/nuxt.config.ts
Normal file
59
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: {enabled: false},
|
||||
ssr: false,
|
||||
css: ['~/assets/css/dark.css'],
|
||||
app: {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||
: '/'
|
||||
},
|
||||
extends: ['@malio/layer-ui'],
|
||||
modules: [
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@pinia/nuxt',
|
||||
'nuxt-toast',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxt/icon',
|
||||
],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
||||
}
|
||||
},
|
||||
devServer: {
|
||||
port: 3003,
|
||||
},
|
||||
components: [
|
||||
{path: '~/components', pathPrefix: false},
|
||||
],
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://nginx',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
settings: {
|
||||
timeout: 2000,
|
||||
closeOnClick: true,
|
||||
progressBar: false
|
||||
}
|
||||
},
|
||||
i18n: {
|
||||
strategy: 'no_prefix',
|
||||
defaultLocale: 'fr',
|
||||
langDir: 'locales',
|
||||
locales: [
|
||||
{code: 'fr', file: 'fr.json', name: 'Français'}
|
||||
],
|
||||
},
|
||||
typescript: {
|
||||
strict: true
|
||||
},
|
||||
})
|
||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.2.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
}
|
||||
12
frontend/pages/index.vue
Normal file
12
frontend/pages/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-900">Tableau de bord</h1>
|
||||
<p class="mt-4 text-neutral-600">Bienvenue sur Central.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Tableau de bord'
|
||||
})
|
||||
</script>
|
||||
68
frontend/pages/login.vue
Normal file
68
frontend/pages/login.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-lg">
|
||||
<span
|
||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||
>
|
||||
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
|
||||
</span>
|
||||
<form
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<MalioInputText
|
||||
label="Nom d'utilisateur"
|
||||
autocomplete="username"
|
||||
group-class="mt-0"
|
||||
inputClass="w-full"
|
||||
v-model="username"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioButton
|
||||
label="Se connecter"
|
||||
button-class="w-full"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({layout: 'auth'})
|
||||
useHead({
|
||||
title: 'Connexion'
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
const {version} = useAppVersion()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
await navigateTo('/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
BIN
frontend/public/LOGO_CARRE.png
Normal file
BIN
frontend/public/LOGO_CARRE.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/malio.png
Normal file
BIN
frontend/public/malio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
22
frontend/services/auth.ts
Normal file
22
frontend/services/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { UserData } from './dto/user-data'
|
||||
|
||||
export function getCurrentUser() {
|
||||
const api = useApi()
|
||||
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
|
||||
}
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
const api = useApi()
|
||||
return api.post('/login_check', { username, password }, {
|
||||
toastOn401: true,
|
||||
toastErrorKey: 'errors.auth.login'
|
||||
})
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
const api = useApi()
|
||||
return api.post('/logout', {}, {
|
||||
toastErrorKey: 'errors.auth.logout',
|
||||
toastSuccessKey: 'success.auth.logout'
|
||||
})
|
||||
}
|
||||
12
frontend/services/dto/user-data.ts
Normal file
12
frontend/services/dto/user-data.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type UserData = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
username: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export type UserWrite = {
|
||||
username: string
|
||||
plainPassword?: string
|
||||
roles: string[]
|
||||
}
|
||||
71
frontend/stores/auth.ts
Normal file
71
frontend/stores/auth.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { getCurrentUser, login, logout } from '~/services/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null as UserData | null,
|
||||
isLoading: false,
|
||||
checked: false
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => Boolean(state.user)
|
||||
},
|
||||
actions: {
|
||||
clearSession() {
|
||||
this.user = null
|
||||
this.checked = true
|
||||
this.isLoading = false
|
||||
},
|
||||
async ensureSession() {
|
||||
if (this.checked) {
|
||||
return this.user
|
||||
}
|
||||
|
||||
this.checked = true
|
||||
|
||||
try {
|
||||
const me = await getCurrentUser()
|
||||
this.user = me
|
||||
return me
|
||||
} catch {
|
||||
this.user = null
|
||||
return null
|
||||
}
|
||||
},
|
||||
async login(username: string, password: string) {
|
||||
this.isLoading = true
|
||||
|
||||
try {
|
||||
await login(username, password)
|
||||
const me = await getCurrentUser()
|
||||
this.user = me
|
||||
this.checked = true
|
||||
return me
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
this.isLoading = true
|
||||
|
||||
try {
|
||||
await logout()
|
||||
} catch {
|
||||
// Ignore logout errors so we can still clear local auth state.
|
||||
} finally {
|
||||
this.user = null
|
||||
this.checked = true
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
async refreshUser() {
|
||||
try {
|
||||
const me = await getCurrentUser()
|
||||
this.user = me
|
||||
} catch {
|
||||
// Silently fail — user session might have expired
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
57
frontend/stores/ui.ts
Normal file
57
frontend/stores/ui.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarOpen = ref(false)
|
||||
const darkMode = ref(false)
|
||||
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem('ui-sidebar-collapsed')
|
||||
if (saved !== null) {
|
||||
sidebarCollapsed.value = saved === 'true'
|
||||
}
|
||||
|
||||
const savedDark = localStorage.getItem('ui-dark-mode')
|
||||
if (savedDark !== null) {
|
||||
darkMode.value = savedDark === 'true'
|
||||
}
|
||||
applyDarkClass(darkMode.value)
|
||||
}
|
||||
|
||||
watch(sidebarCollapsed, (val) => {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem('ui-sidebar-collapsed', String(val))
|
||||
}
|
||||
})
|
||||
|
||||
watch(darkMode, (val) => {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem('ui-dark-mode', String(val))
|
||||
applyDarkClass(val)
|
||||
}
|
||||
})
|
||||
|
||||
function applyDarkClass(dark: boolean) {
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
function openMobileSidebar() {
|
||||
sidebarOpen.value = true
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
return { sidebarCollapsed, sidebarOpen, darkMode, toggleSidebar, openMobileSidebar, closeMobileSidebar, toggleDarkMode }
|
||||
})
|
||||
48
frontend/tailwind.config.ts
Normal file
48
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type {Config} from 'tailwindcss'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
500: '#222783',
|
||||
},
|
||||
secondary: {
|
||||
500: '#304998'
|
||||
},
|
||||
tertiary: {
|
||||
500: '#F3F4F8'
|
||||
},
|
||||
blue: {
|
||||
500: '#056CF2'
|
||||
},
|
||||
m: {
|
||||
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
||||
secondary: 'rgb(var(--m-secondary, 75 77 237) / <alpha-value>)',
|
||||
tertiary: 'rgb(var(--m-tertiary, 243 244 248) / <alpha-value>)',
|
||||
border: 'rgb(var(--m-border) / <alpha-value>)',
|
||||
text: 'rgb(var(--m-text) / <alpha-value>)',
|
||||
muted: 'rgb(var(--m-muted) / <alpha-value>)',
|
||||
bg: 'rgb(var(--m-bg) / <alpha-value>)',
|
||||
surface: 'rgb(var(--m-surface) / <alpha-value>)',
|
||||
disabled: 'rgb(var(--m-disabled) / <alpha-value>)',
|
||||
danger: 'rgb(var(--m-danger) / <alpha-value>)',
|
||||
success: 'rgb(var(--m-success) / <alpha-value>)',
|
||||
'btn-primary': 'rgb(var(--m-btn-primary) / <alpha-value>)',
|
||||
'btn-primary-hover': 'rgb(var(--m-btn-primary-hover) / <alpha-value>)',
|
||||
'btn-primary-active': 'rgb(var(--m-btn-primary-active) / <alpha-value>)',
|
||||
'btn-secondary': 'rgb(var(--m-btn-secondary) / <alpha-value>)',
|
||||
'btn-secondary-hover': 'rgb(var(--m-btn-secondary-hover) / <alpha-value>)',
|
||||
'btn-secondary-active': 'rgb(var(--m-btn-secondary-active) / <alpha-value>)',
|
||||
'btn-danger': 'rgb(var(--m-btn-danger) / <alpha-value>)',
|
||||
'btn-danger-hover': 'rgb(var(--m-btn-danger-hover) / <alpha-value>)',
|
||||
'btn-danger-active': 'rgb(var(--m-btn-danger-active) / <alpha-value>)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
frontend/utils/api.ts
Normal file
10
frontend/utils/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type HydraCollection<T> = {
|
||||
'hydra:member'?: T[]
|
||||
'hydra:totalItems'?: number
|
||||
'member'?: T[]
|
||||
'totalItems'?: number
|
||||
}
|
||||
|
||||
export function extractHydraMembers<T>(response: HydraCollection<T>): T[] {
|
||||
return response['hydra:member'] ?? response['member'] ?? []
|
||||
}
|
||||
Reference in New Issue
Block a user