feat: dark mode complet + préférences utilisateur

- Ajout du store userPreferencesStore (thème, vue, tri, pagination, lecteur)
- Page UserPreferencesPage pour configurer toutes les préférences
- Câblage des prefs dans HomePage (viewMode, sortBy, itemsPerPage), readerStore (fallback prefs), ChapterReader (autoHide, autoFullscreen, sync), useNotifications (toastDuration)
- Thème sombre (dark: Tailwind) sur tous les composants Vue : Layout, Pagination, NotificationToast, MangaCard, MangaVolume, MangaDetails, AddManga, HomePage, ActivityPage, JobItem, MangaDeleteModal, MangaEditModal, MangaPreferredSourcesModal, ManageChaptersModal, MangaChapterList, MangaChapter, ConversionPage, FileUploadArea, ConversionProgress, NewImportPage, FileImportCard, MangaMatchCard, StatusBadge, ImportResults
- i18n partiellement initialisé

Jeremy Guillot
This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-12 20:38:29 +01:00
parent 48d819ba72
commit ec1ef8fe68
36 changed files with 2832 additions and 317 deletions

View File

@@ -0,0 +1,142 @@
import { defineStore } from 'pinia';
const STORAGE_KEY = 'mangarr_preferences';
const defaultState = {
theme: 'system',
language: 'fr',
defaultView: 'grid',
itemsPerPage: 20,
sortBy: 'title',
readingDirection: 'ltr',
readingMode: 'scroll',
autoFullscreen: false,
autoHideHeaderReader: true,
toastDuration: 5000,
};
function loadFromStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return { ...defaultState, ...JSON.parse(stored) };
}
} catch {
// ignore parse errors
}
return { ...defaultState };
}
let mediaQueryUnsubscribe = null;
export const useUserPreferencesStore = defineStore('userPreferences', {
state: () => loadFromStorage(),
actions: {
applyTheme() {
// Nettoyer le listener précédent
if (mediaQueryUnsubscribe) {
mediaQueryUnsubscribe();
mediaQueryUnsubscribe = null;
}
const html = document.documentElement;
if (this.theme === 'dark') {
html.classList.add('dark');
} else if (this.theme === 'light') {
html.classList.remove('dark');
} else {
// mode 'system'
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => {
if (e.matches) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
};
handler(mq);
mq.addEventListener('change', handler);
mediaQueryUnsubscribe = () => mq.removeEventListener('change', handler);
}
},
setTheme(theme) {
this.theme = theme;
this.persist();
this.applyTheme();
},
setLanguage(language) {
this.language = language;
this.persist();
},
setDefaultView(view) {
this.defaultView = view;
this.persist();
},
setItemsPerPage(count) {
this.itemsPerPage = count;
this.persist();
},
setSortBy(sort) {
this.sortBy = sort;
this.persist();
},
setReadingDirection(direction) {
this.readingDirection = direction;
this.persist();
},
setReadingMode(mode) {
this.readingMode = mode;
this.persist();
},
setAutoFullscreen(value) {
this.autoFullscreen = value;
this.persist();
},
setAutoHideHeaderReader(value) {
this.autoHideHeaderReader = value;
this.persist();
},
setToastDuration(duration) {
this.toastDuration = duration;
this.persist();
},
resetToDefaults() {
Object.assign(this, defaultState);
this.persist();
this.applyTheme();
},
persist() {
try {
const data = {
theme: this.theme,
language: this.language,
defaultView: this.defaultView,
itemsPerPage: this.itemsPerPage,
sortBy: this.sortBy,
readingDirection: this.readingDirection,
readingMode: this.readingMode,
autoFullscreen: this.autoFullscreen,
autoHideHeaderReader: this.autoHideHeaderReader,
toastDuration: this.toastDuration,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// ignore storage errors
}
},
},
});

View File

@@ -0,0 +1,242 @@
<template>
<div class="container mx-auto px-4 py-8 max-w-3xl">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('preferences.title') }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('preferences.subtitle') }}</p>
</div>
<button
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
@click="handleReset">
{{ t('preferences.reset') }}
</button>
</div>
<!-- Apparence -->
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-6 pt-5 pb-3">
{{ t('preferences.sections.appearance') }}
</h2>
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Thème -->
<div class="flex items-center justify-between px-6 py-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.theme.label') }}</label>
<select
:value="store.theme"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="store.setTheme($event.target.value)">
<option value="light">{{ t('preferences.theme.light') }}</option>
<option value="dark">{{ t('preferences.theme.dark') }}</option>
<option value="system">{{ t('preferences.theme.system') }}</option>
</select>
</div>
<!-- Langue -->
<div class="flex items-center justify-between px-6 py-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.language.label') }}</label>
<select
:value="store.language"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="handleLanguageChange($event.target.value)">
<option value="fr">{{ t('preferences.language.fr') }}</option>
<option value="en">{{ t('preferences.language.en') }}</option>
</select>
</div>
</div>
</section>
<!-- Affichage collection -->
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-6 pt-5 pb-3">
{{ t('preferences.sections.collection') }}
</h2>
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Vue par défaut -->
<div class="flex items-center justify-between px-6 py-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.defaultView.label') }}</label>
<div class="flex gap-2">
<button
:class="viewButtonClass('grid')"
@click="store.setDefaultView('grid')">
{{ t('preferences.defaultView.grid') }}
</button>
<button
:class="viewButtonClass('list')"
@click="store.setDefaultView('list')">
{{ t('preferences.defaultView.list') }}
</button>
</div>
</div>
<!-- Mangas par page -->
<div class="flex items-center justify-between px-6 py-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.itemsPerPage.label') }}</label>
<div class="flex gap-2">
<button
v-for="n in [12, 20, 40]"
:key="n"
:class="countButtonClass(n)"
@click="store.setItemsPerPage(n)">
{{ n }}
</button>
</div>
</div>
<!-- Tri par défaut -->
<div class="flex items-center justify-between px-6 py-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.sortBy.label') }}</label>
<select
:value="store.sortBy"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="store.setSortBy($event.target.value)">
<option value="title">{{ t('preferences.sortBy.title') }}</option>
<option value="addedAt">{{ t('preferences.sortBy.addedAt') }}</option>
<option value="progress">{{ t('preferences.sortBy.progress') }}</option>
</select>
</div>
</div>
</section>
<!-- Lecture -->
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-6 pt-5 pb-3">
{{ t('preferences.sections.reading') }}
</h2>
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Direction de lecture -->
<div class="flex items-center justify-between px-6 py-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingDirection.label') }}</label>
<select
:value="store.readingDirection"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="store.setReadingDirection($event.target.value)">
<option value="ltr">{{ t('preferences.readingDirection.ltr') }}</option>
<option value="rtl">{{ t('preferences.readingDirection.rtl') }}</option>
</select>
</div>
<!-- Mode d'affichage -->
<div class="flex items-center justify-between px-6 py-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingMode.label') }}</label>
<select
:value="store.readingMode"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="store.setReadingMode($event.target.value)">
<option value="scroll">{{ t('preferences.readingMode.scroll') }}</option>
<option value="single">{{ t('preferences.readingMode.single') }}</option>
<option value="double">{{ t('preferences.readingMode.double') }}</option>
</select>
</div>
<!-- Auto plein écran -->
<div class="flex items-center justify-between px-6 py-4">
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoFullscreen.label') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoFullscreen.description') }}</p>
</div>
<button
:class="toggleClass(store.autoFullscreen)"
role="switch"
:aria-checked="store.autoFullscreen"
@click="store.setAutoFullscreen(!store.autoFullscreen)">
<span :class="toggleKnobClass(store.autoFullscreen)" />
</button>
</div>
<!-- Auto-hide header -->
<div class="flex items-center justify-between px-6 py-4">
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoHideHeaderReader.label') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoHideHeaderReader.description') }}</p>
</div>
<button
:class="toggleClass(store.autoHideHeaderReader)"
role="switch"
:aria-checked="store.autoHideHeaderReader"
@click="store.setAutoHideHeaderReader(!store.autoHideHeaderReader)">
<span :class="toggleKnobClass(store.autoHideHeaderReader)" />
</button>
</div>
</div>
</section>
<!-- Notifications -->
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-6 pt-5 pb-3">
{{ t('preferences.sections.notifications') }}
</h2>
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Durée des toasts -->
<div class="flex items-center justify-between px-6 py-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.toastDuration.label') }}</label>
<div class="flex gap-2">
<button
v-for="[val, label] in toastOptions"
:key="val"
:class="countButtonClass(val, store.toastDuration)"
@click="store.setToastDuration(val)">
{{ t(label) }}
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n';
import { useUserPreferencesStore } from '../../application/store/userPreferencesStore';
import { i18n } from '../../../../shared/i18n';
const { t, locale } = useI18n();
const store = useUserPreferencesStore();
const toastOptions = [
[3000, 'preferences.toastDuration.3s'],
[5000, 'preferences.toastDuration.5s'],
[10000, 'preferences.toastDuration.10s'],
];
function handleLanguageChange(lang) {
store.setLanguage(lang);
i18n.global.locale.value = lang;
locale.value = lang;
}
function handleReset() {
if (confirm(t('preferences.resetConfirm'))) {
store.resetToDefaults();
i18n.global.locale.value = store.language;
locale.value = store.language;
}
}
function viewButtonClass(view) {
const active = store.defaultView === view;
return [
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
active
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
];
}
function countButtonClass(val, current = store.itemsPerPage) {
const active = current === val;
return [
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
active
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
];
}
function toggleClass(active) {
return [
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
active ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600',
];
}
function toggleKnobClass(active) {
return [
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
active ? 'translate-x-6' : 'translate-x-1',
];
}
</script>