Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/types-builder/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ const emit = defineEmits(emits)
for (const locale of vjsfLocales) {
const schemaVjsfOpts = { ...schema['x-vjsf'] }
delete schemaVjsfOpts.compName
console.log(` compiledLayout ${locale} options: ${JSON.stringify(schemaVjsfOpts)}`)
const otherSchemas = { ...schemas }
for (const [key, otherSchema] of Object.entries(schemas)) {
if (key === schema.$id) continue
Expand All @@ -375,7 +376,6 @@ const emit = defineEmits(emits)
fullOptions.components[componentInfo.name] = componentInfo
}

console.log(` compiledLayout ${locale} options: ${JSON.stringify(schemaVjsfOpts)}`)
const compiledLayout = compileLayout(schema, fullOptions)
let compiledLayoutCode = await serializeCompiledLayout(compiledLayout)
// The serialized code declares `const compiledLayout = {...}`.
Expand Down
61 changes: 48 additions & 13 deletions packages/vue/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,16 @@ export interface Colors {
'on-admin': string
}

interface FullSiteInfo {
export interface FullSiteInfo {
main?: boolean
theme: {
logo?: string
colors: Colors
dark: boolean
dark?: boolean
darkColors?: Colors
hc: boolean
hc?: boolean
hcColors?: Colors
hcDark: boolean
hcDark?: boolean
hcDarkColors?: Colors
}
}
Expand All @@ -82,7 +82,14 @@ export interface SiteInfo {
owner: AccountKeys
}

type Theme = 'default' | 'dark' | 'hc' | 'hc-dark'
export type AppliedTheme = 'default' | 'dark' | 'hc' | 'hc-dark'
// `theme` cookie semantics:
// - absent: implicit 'system' (no choice made yet)
// - 'system': explicit "follow the OS preference"
// - other: explicit override
// In both 'system' cases the applied theme is computed at runtime via
// resolveTheme() using prefers-color-scheme + forced-colors.
export type Theme = AppliedTheme | 'system'

export interface Session {
state: SessionState
Expand Down Expand Up @@ -120,10 +127,26 @@ export type SessionAuthenticated = Omit<Session, 'state' | 'user' | 'account' |
const debug = Debug('session')
debug.log = console.log.bind(console)

function getDefaultTheme (site: FullSiteInfo): Theme {
// loose shape: hosts whose Colors are partially optional (e.g. portal config) can pass their own type
export type ThemeOffers = {
theme: {
dark?: boolean
hc?: boolean
hcDark?: boolean
}
}

/**
* Resolves a user theme preference to a concrete AppliedTheme — returns the explicit choice when set,
* otherwise picks the best variant offered by `site` based on the OS `prefers-color-scheme` / `forced-colors`
* media queries. Always call this before handing a theme name to Vuetify: its built-in `'system'` defaultTheme
* bypasses custom themes and falls back to its own light/dark.
*/
export function resolveTheme (userTheme: Theme | null, site: ThemeOffers): AppliedTheme {
if (userTheme && userTheme !== 'system') return userTheme
// see https://www.scottohara.me/blog/2021/10/01/detect-high-contrast-and-dark-modes.html
const preferDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const preferHC = window.matchMedia && window.matchMedia('(forced-colors: active)').matches
const preferDark = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const preferHC = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(forced-colors: active)').matches
if (site.theme.hcDark && preferDark && preferHC) return 'hc-dark'
if (site.theme.hc && preferHC) return 'hc'
if (site.theme.dark && preferDark) return 'dark'
Expand Down Expand Up @@ -194,7 +217,9 @@ export async function getSession (initOptions: Partial<SessionOptions>): Promise
// cookies are the source of truth and this information is transformed into the state reactive object
const cookies = initOptions?.cookies ?? new Cookies(options.req?.headers.cookie)
const readState = () => {
theme.value = cookies.get('theme') ?? null
// absent cookie is treated as implicit 'system' so consumers (theme-switcher
// radios, host plugins) always have a meaningful value to bind to.
theme.value = (cookies.get('theme') as Theme | undefined) ?? 'system'

const langCookie = cookies.get('i18n_lang')
state.lang = langCookie ?? options.defaultLang
Expand Down Expand Up @@ -412,13 +437,13 @@ export async function getSession (initOptions: Partial<SessionOptions>): Promise
authOnlyOtherSite: siteInfo.authOnlyOtherSite,
owner: siteInfo.owner
}
if (theme.value == null) theme.value = getDefaultTheme(siteInfo)
if (theme.value === 'hc') partialSite.colors = siteInfo.theme.hcColors
if (theme.value === 'dark') {
const applied = resolveTheme(theme.value, siteInfo)
if (applied === 'hc') partialSite.colors = siteInfo.theme.hcColors
if (applied === 'dark') {
partialSite.colors = siteInfo.theme.darkColors
partialSite.dark = true
}
if (theme.value === 'hc-dark') {
if (applied === 'hc-dark') {
partialSite.colors = siteInfo.theme.hcDarkColors
partialSite.dark = true
}
Expand All @@ -433,6 +458,16 @@ export async function getSession (initOptions: Partial<SessionOptions>): Promise
// @ts-ignore
if (!ssr && window.__PUBLIC_SITE_INFO) setSiteInfo(window.__PUBLIC_SITE_INFO)

// re-apply the theme when the OS preference changes while the user is on
// 'system'. Important for mobile devices that switch light/dark over the day.
if (!ssr && typeof window !== 'undefined' && window.matchMedia) {
const onOsPrefChange = () => {
if (theme.value === 'system' && fullSite.value) setSiteInfo(fullSite.value)
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', onOsPrefChange)
window.matchMedia('(forced-colors: active)').addEventListener('change', onOsPrefChange)
}

// immediately performs a keepalive, but only on top windows (not iframes or popups)
// and only if it was not done very recently (maybe from a refreshed page next to this one)
// also run an auto-refresh loop
Expand Down
9 changes: 6 additions & 3 deletions packages/vuetify/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Session } from '@data-fair/lib-vue/session.js'
import { resolveTheme, type Session } from '@data-fair/lib-vue/session.js'
import { VuetifyOptions } from 'vuetify'
import { fr, en } from 'vuetify/locale'

Expand All @@ -20,6 +20,9 @@ const baseDarkColors = {
export function vuetifySessionOptions (session: Session, cspNonce?: string): VuetifyOptions {
if (!session.site.value) throw new Error('vuetifySessionOptions requires fetching site info in session util')
const colors = { ...baseColors, ...session.site.value?.colors }
const themeName = session.fullSite.value
? resolveTheme(session.theme.value, session.fullSite.value)
: 'default'
return {
ssr: false,
locale: {
Expand All @@ -28,9 +31,9 @@ export function vuetifySessionOptions (session: Session, cspNonce?: string): Vue
},
theme: {
cspNonce,
defaultTheme: session.theme.value ?? 'default',
defaultTheme: themeName,
themes: {
[session.theme.value ?? 'default']: {
[themeName]: {
dark: session.site.value?.dark,
colors,
variables: {
Expand Down
24 changes: 20 additions & 4 deletions packages/vuetify/layout-fetch-error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const { error, backTo = '/', backLabel } = defineProps<{
}>()

const { t } = useI18n({ useScope: 'local' })
const { switchOrganization, user } = useSession()
const { switchOrganization, user, account } = useSession()

const statusCode = computed(() => error?.statusCode ?? error?.status ?? 500)

Expand Down Expand Up @@ -99,9 +99,25 @@ const switchOrg = computed(() => {
try { owner = JSON.parse(rawOwner) } catch { return null }
if (!owner || owner.type !== 'organization' || !owner.id) return null

return user.value.organizations?.find(o =>
o.id === owner!.id && (o.department ?? undefined) === (owner!.department ?? undefined)
) ?? null
const orgs = user.value.organizations ?? []
const ownerDept = owner.department ?? undefined
const isCurrentAccount = (o: { id: string, department?: string }) =>
account.value?.type === 'organization' &&
account.value.id === o.id &&
(account.value.department ?? undefined) === (o.department ?? undefined)

// Prefer a membership matching the resource's department exactly...
const exact = orgs.find(o =>
o.id === owner!.id && (o.department ?? undefined) === ownerDept && !isCurrentAccount(o)
)
if (exact) return exact
// ...otherwise fall back to a membership at the organization root: in
// simple-directory's authz model, root access generally grants visibility
// over department-scoped resources.
if (ownerDept) {
return orgs.find(o => o.id === owner!.id && !o.department && !isCurrentAccount(o)) ?? null
}
return null
})

const doSwitch = () => {
Expand Down
6 changes: 2 additions & 4 deletions packages/vuetify/personal-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,8 @@ fr:
openPersonalMenu: Ouvrez le menu personnel
personalAccount: Compte personnel
switchAccount: Changer de compte
adminMode: mode admin
adminMode: Mode admin
backToAdmin: Revenir à ma session administrateur
darkMode: mode nuit
plannedDeletion: La suppression de l'utilisateur {name} et toutes ses informations est programmée le {plannedDeletion}.
cancelDeletion: Annuler la suppression de l'utilisateur
en:
Expand All @@ -193,9 +192,8 @@ en:
openPersonalMenu: Open personal menu
personalAccount: Personal account
switchAccount: Switch account
adminMode: admin mode
adminMode: Admin mode
backToAdmin: Return to administrator session
darkMode: night mode
plannedDeletion: The deletion of the user {name} and all its data is planned on the {plannedDeletion}.
cancelDeletion: Cancel the deletion of the user
</i18n>
Expand Down
52 changes: 34 additions & 18 deletions packages/vuetify/theme-switcher.vue
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
<template>
<v-toolbar-items class="theme-switcher" v-if="session.fullSite.value?.theme.dark || session.fullSite.value?.theme.hc || session.fullSite.value?.theme.hcDark">
<v-toolbar-items v-if="hasDark || hasHc || hasHcDark">
<v-menu
offset-y
class="ml-n4"
style="z-index: 2600; /* Higher than agent-chat's 2500 */"
>
<template #activator="{props: activatorProps}">
<template #activator="{ props }">
<v-btn
class="px-0"
:icon="mdiThemeLightDark"
v-bind="props"
:title="t('themeSwitch')"
v-bind="activatorProps"
:icon="mdiThemeLightDark"
stacked
/>
</template>

<v-list class="border-sm">
<v-list>
<v-list-item density="compact" class="pl-0">
<v-radio-group
:model-value="session.theme.value"
density="comfortable"
color="primary"
hide-details
:label="t('themeSwitch')"
@update:modelValue="value => session.switchTheme(value as 'default' | 'dark' | 'hc' | 'hc-dark')"
@update:modelValue="value => session.switchTheme(value as 'default' | 'dark' | 'hc' | 'hc-dark' | 'system')"
>
<v-radio :label="t('theme.system')" value="system"></v-radio>
<v-radio :label="t('theme.default')" value="default"></v-radio>
<v-radio v-if="session.fullSite.value?.theme.dark" :label="t('theme.dark')" value="dark"></v-radio>
<v-radio v-if="session.fullSite.value?.theme.hc":label="t('theme.hc')" value="hc"></v-radio>
<v-radio v-if="session.fullSite.value?.theme.hcDark" :label="t('theme.hcDark')" value="hc-dark"></v-radio>
<v-radio v-if="hasDark" :label="t('theme.dark')" value="dark"></v-radio>
<v-radio v-if="hasHc" :label="t('theme.hc')" value="hc"></v-radio>
<v-radio v-if="hasHcDark" :label="t('theme.hcDark')" value="hc-dark"></v-radio>
</v-radio-group>
</v-list-item>
</v-list>
Expand All @@ -39,27 +40,42 @@
fr:
themeSwitch: Changer de thème
theme:
default: par défaut
dark: sombre
hc: contraste élevé
hcDark: sombre et contraste élevé
system: Système
default: Par défaut
dark: Sombre
hc: Contraste élevé
hcDark: Sombre et contraste élevé
en:
themeSwitch: Change theme
theme:
default: default
dark: dark
hc: high contrast
hcDark: dark and high contrast
system: System
default: Default
dark: Dark
hc: High contrast
hcDark: Dark and high contrast
</i18n>

<script setup lang="ts">
import { computed } from 'vue'
import { useSession } from '@data-fair/lib-vue/session.js'
import { useI18n } from 'vue-i18n'
import { mdiThemeLightDark } from '@mdi/js'

// Optional `theme` lets hosts that don't expose their config through
// session.fullSite (e.g. the public Nuxt portal, where the theme lives in
// portalConfig) drive which radios are shown. Falls back to
// session.fullSite.value?.theme — the historical behaviour for data-fair UIs.
const props = defineProps<{
theme?: { dark?: boolean, hc?: boolean, hcDark?: boolean }
}>()

const session = useSession()

const effectiveTheme = computed(() => props.theme ?? session.fullSite.value?.theme)
const hasDark = computed(() => !!effectiveTheme.value?.dark)
const hasHc = computed(() => !!effectiveTheme.value?.hc)
const hasHcDark = computed(() => !!effectiveTheme.value?.hcDark)

const { t } = useI18n({ useScope: 'local' })
</script>

Expand Down