mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-18 18:54:07 +00:00
Phase 1: TypeScript foundation in scripts-v2/
Create the complete TypeScript foundation for the Vue 3 migration in a parallel scripts-v2/ directory. 72 files, 5430 lines, zero any types, strict mode. - types/ (21 files): Domain interfaces for all 17 entities derived from actual Laravel models and API resources. Enums for all statuses. Generic API response wrappers. - api/ (29 files): Typed axios client with interceptors, endpoint constants from routes/api.php, 25 typed service classes covering every API endpoint. - composables/ (14 files): Vue 3 composition functions for auth, notifications, dialogs, modals, pagination, filters, currency, dates, theme, sidebar, company context, and permissions. - utils/ (5 files): Pure typed utilities for money formatting, date formatting (date-fns), localStorage, and error handling. - config/ (3 files): Typed ability constants, app constants. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
resources/scripts-v2/composables/index.ts
Normal file
54
resources/scripts-v2/composables/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export { useApi } from './use-api'
|
||||
export type { UseApiReturn } from './use-api'
|
||||
|
||||
export { useAuth } from './use-auth'
|
||||
export type { User, UseAuthReturn } from './use-auth'
|
||||
|
||||
export { useNotification } from './use-notification'
|
||||
export type { Notification, UseNotificationReturn } from './use-notification'
|
||||
|
||||
export { useDialog } from './use-dialog'
|
||||
export type {
|
||||
DialogState,
|
||||
OpenConfirmOptions,
|
||||
UseDialogReturn,
|
||||
} from './use-dialog'
|
||||
|
||||
export { useModal } from './use-modal'
|
||||
export type {
|
||||
ModalState,
|
||||
OpenModalOptions,
|
||||
UseModalReturn,
|
||||
} from './use-modal'
|
||||
|
||||
export { usePagination } from './use-pagination'
|
||||
export type {
|
||||
UsePaginationOptions,
|
||||
UsePaginationReturn,
|
||||
} from './use-pagination'
|
||||
|
||||
export { useFilters } from './use-filters'
|
||||
export type { UseFiltersOptions, UseFiltersReturn } from './use-filters'
|
||||
|
||||
export { useCurrency } from './use-currency'
|
||||
export type { Currency, UseCurrencyReturn } from './use-currency'
|
||||
|
||||
export { useDate } from './use-date'
|
||||
export type { UseDateReturn } from './use-date'
|
||||
|
||||
export { useTheme } from './use-theme'
|
||||
export type { UseThemeReturn } from './use-theme'
|
||||
|
||||
export { useSidebar } from './use-sidebar'
|
||||
export type { UseSidebarReturn } from './use-sidebar'
|
||||
|
||||
export { useCompany } from './use-company'
|
||||
export type {
|
||||
Company,
|
||||
CompanySettings,
|
||||
CompanyCurrency,
|
||||
UseCompanyReturn,
|
||||
} from './use-company'
|
||||
|
||||
export { usePermissions } from './use-permissions'
|
||||
export type { UserAbility, UsePermissionsReturn } from './use-permissions'
|
||||
52
resources/scripts-v2/composables/use-api.ts
Normal file
52
resources/scripts-v2/composables/use-api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { handleApiError } from '../utils/error-handling'
|
||||
import type { NormalizedApiError } from '../utils/error-handling'
|
||||
|
||||
export interface UseApiReturn<T> {
|
||||
data: Ref<T | null>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<NormalizedApiError | null>
|
||||
execute: (...args: unknown[]) => Promise<T | null>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API call wrapper composable.
|
||||
* Manages loading, error, and data state for an async API function.
|
||||
*
|
||||
* @param apiFn - An async function that performs the API call
|
||||
* @returns Reactive refs for data, loading, and error plus an execute function
|
||||
*/
|
||||
export function useApi<T>(
|
||||
apiFn: (...args: never[]) => Promise<T>
|
||||
): UseApiReturn<T> {
|
||||
const data = ref<T | null>(null) as Ref<T | null>
|
||||
const loading = ref<boolean>(false)
|
||||
const error = ref<NormalizedApiError | null>(null) as Ref<NormalizedApiError | null>
|
||||
|
||||
async function execute(...args: unknown[]): Promise<T | null> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await (apiFn as (...a: unknown[]) => Promise<T>)(...args)
|
||||
data.value = result
|
||||
return result
|
||||
} catch (err: unknown) {
|
||||
const normalized = handleApiError(err)
|
||||
error.value = normalized
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
data.value = null
|
||||
loading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return { data, loading, error, execute, reset }
|
||||
}
|
||||
86
resources/scripts-v2/composables/use-auth.ts
Normal file
86
resources/scripts-v2/composables/use-auth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import * as ls from '../utils/local-storage'
|
||||
import { LS_KEYS } from '../config/constants'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
avatar: string | number
|
||||
is_owner: boolean
|
||||
is_super_admin: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UseAuthReturn {
|
||||
currentUser: Ref<User | null>
|
||||
isAuthenticated: ComputedRef<boolean>
|
||||
isOwner: ComputedRef<boolean>
|
||||
isSuperAdmin: ComputedRef<boolean>
|
||||
setUser: (user: User) => void
|
||||
clearUser: () => void
|
||||
login: (loginFn: () => Promise<User>) => Promise<User>
|
||||
logout: (logoutFn: () => Promise<void>) => Promise<void>
|
||||
}
|
||||
|
||||
const currentUser = ref<User | null>(null)
|
||||
|
||||
/**
|
||||
* Composable for managing authentication state.
|
||||
* Provides the current user, login/logout helpers, and role-based computed properties.
|
||||
*/
|
||||
export function useAuth(): UseAuthReturn {
|
||||
const isAuthenticated = computed<boolean>(() => currentUser.value !== null)
|
||||
|
||||
const isOwner = computed<boolean>(
|
||||
() => currentUser.value?.is_owner === true
|
||||
)
|
||||
|
||||
const isSuperAdmin = computed<boolean>(
|
||||
() => currentUser.value?.is_super_admin === true
|
||||
)
|
||||
|
||||
function setUser(user: User): void {
|
||||
currentUser.value = user
|
||||
}
|
||||
|
||||
function clearUser(): void {
|
||||
currentUser.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a login function and set the current user on success.
|
||||
*
|
||||
* @param loginFn - Async function that performs the login and returns a User
|
||||
* @returns The authenticated user
|
||||
*/
|
||||
async function login(loginFn: () => Promise<User>): Promise<User> {
|
||||
const user = await loginFn()
|
||||
currentUser.value = user
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a logout function and clear auth state.
|
||||
*
|
||||
* @param logoutFn - Async function that performs the logout
|
||||
*/
|
||||
async function logout(logoutFn: () => Promise<void>): Promise<void> {
|
||||
await logoutFn()
|
||||
currentUser.value = null
|
||||
ls.remove(LS_KEYS.AUTH_TOKEN)
|
||||
ls.remove(LS_KEYS.SELECTED_COMPANY)
|
||||
}
|
||||
|
||||
return {
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
isOwner,
|
||||
isSuperAdmin,
|
||||
setUser,
|
||||
clearUser,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
134
resources/scripts-v2/composables/use-company.ts
Normal file
134
resources/scripts-v2/composables/use-company.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import * as ls from '../utils/local-storage'
|
||||
import { LS_KEYS } from '../config/constants'
|
||||
|
||||
export interface Company {
|
||||
id: number
|
||||
unique_hash: string
|
||||
name: string
|
||||
slug: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface CompanySettings {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface CompanyCurrency {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
symbol: string
|
||||
precision: number
|
||||
thousand_separator: string
|
||||
decimal_separator: string
|
||||
swap_currency_symbol?: boolean
|
||||
exchange_rate?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UseCompanyReturn {
|
||||
selectedCompany: Ref<Company | null>
|
||||
companies: Ref<Company[]>
|
||||
selectedCompanySettings: Ref<CompanySettings>
|
||||
selectedCompanyCurrency: Ref<CompanyCurrency | null>
|
||||
isAdminMode: Ref<boolean>
|
||||
hasCompany: ComputedRef<boolean>
|
||||
setCompany: (company: Company | null) => void
|
||||
setCompanies: (data: Company[]) => void
|
||||
setCompanySettings: (settings: CompanySettings) => void
|
||||
setCompanyCurrency: (currency: CompanyCurrency | null) => void
|
||||
enterAdminMode: () => void
|
||||
exitAdminMode: () => void
|
||||
}
|
||||
|
||||
const selectedCompany = ref<Company | null>(null)
|
||||
const companies = ref<Company[]>([])
|
||||
const selectedCompanySettings = ref<CompanySettings>({})
|
||||
const selectedCompanyCurrency = ref<CompanyCurrency | null>(null)
|
||||
const isAdminMode = ref<boolean>(
|
||||
ls.get<string>(LS_KEYS.IS_ADMIN_MODE) === 'true'
|
||||
)
|
||||
|
||||
/**
|
||||
* Composable for managing company selection and admin mode state.
|
||||
* Extracted from the Pinia company store, this provides reactive company state
|
||||
* with localStorage persistence for the selected company and admin mode.
|
||||
*/
|
||||
export function useCompany(): UseCompanyReturn {
|
||||
const hasCompany = computed<boolean>(
|
||||
() => selectedCompany.value !== null
|
||||
)
|
||||
|
||||
/**
|
||||
* Set the selected company and persist to localStorage.
|
||||
* Automatically disables admin mode when a company is selected.
|
||||
*
|
||||
* @param company - The company to select, or null to deselect
|
||||
*/
|
||||
function setCompany(company: Company | null): void {
|
||||
if (company) {
|
||||
ls.set(LS_KEYS.SELECTED_COMPANY, String(company.id))
|
||||
ls.remove(LS_KEYS.IS_ADMIN_MODE)
|
||||
isAdminMode.value = false
|
||||
} else {
|
||||
ls.remove(LS_KEYS.SELECTED_COMPANY)
|
||||
}
|
||||
selectedCompany.value = company
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the list of available companies.
|
||||
*/
|
||||
function setCompanies(data: Company[]): void {
|
||||
companies.value = data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selected company's settings.
|
||||
*/
|
||||
function setCompanySettings(settings: CompanySettings): void {
|
||||
selectedCompanySettings.value = settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selected company's currency.
|
||||
*/
|
||||
function setCompanyCurrency(currency: CompanyCurrency | null): void {
|
||||
selectedCompanyCurrency.value = currency
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter admin mode. Clears the selected company and persists admin mode.
|
||||
*/
|
||||
function enterAdminMode(): void {
|
||||
isAdminMode.value = true
|
||||
ls.set(LS_KEYS.IS_ADMIN_MODE, 'true')
|
||||
ls.remove(LS_KEYS.SELECTED_COMPANY)
|
||||
selectedCompany.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit admin mode.
|
||||
*/
|
||||
function exitAdminMode(): void {
|
||||
isAdminMode.value = false
|
||||
ls.remove(LS_KEYS.IS_ADMIN_MODE)
|
||||
}
|
||||
|
||||
return {
|
||||
selectedCompany,
|
||||
companies,
|
||||
selectedCompanySettings,
|
||||
selectedCompanyCurrency,
|
||||
isAdminMode,
|
||||
hasCompany,
|
||||
setCompany,
|
||||
setCompanies,
|
||||
setCompanySettings,
|
||||
setCompanyCurrency,
|
||||
enterAdminMode,
|
||||
exitAdminMode,
|
||||
}
|
||||
}
|
||||
105
resources/scripts-v2/composables/use-currency.ts
Normal file
105
resources/scripts-v2/composables/use-currency.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { formatMoney as formatMoneyUtil } from '../utils/format-money'
|
||||
import type { CurrencyConfig } from '../utils/format-money'
|
||||
|
||||
export interface Currency {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
precision: number
|
||||
thousand_separator: string
|
||||
decimal_separator: string
|
||||
symbol: string
|
||||
swap_currency_symbol?: boolean
|
||||
exchange_rate?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UseCurrencyReturn {
|
||||
currencies: Ref<Currency[]>
|
||||
setCurrencies: (data: Currency[]) => void
|
||||
formatMoney: (amountInCents: number, currency?: CurrencyConfig) => string
|
||||
convertCurrency: (
|
||||
amountInCents: number,
|
||||
fromRate: number,
|
||||
toRate: number
|
||||
) => number
|
||||
findCurrencyByCode: (code: string) => Currency | undefined
|
||||
}
|
||||
|
||||
const currencies = ref<Currency[]>([])
|
||||
|
||||
/**
|
||||
* Default currency configuration matching the v1 behavior.
|
||||
*/
|
||||
const DEFAULT_CURRENCY_CONFIG: CurrencyConfig = {
|
||||
precision: 2,
|
||||
thousand_separator: ',',
|
||||
decimal_separator: '.',
|
||||
symbol: '$',
|
||||
swap_currency_symbol: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for currency formatting and conversion.
|
||||
* Maintains a shared list of available currencies.
|
||||
*/
|
||||
export function useCurrency(): UseCurrencyReturn {
|
||||
function setCurrencies(data: Currency[]): void {
|
||||
currencies.value = data
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an amount in cents using the provided or default currency config.
|
||||
*
|
||||
* @param amountInCents - Amount in cents
|
||||
* @param currency - Optional currency config override
|
||||
* @returns Formatted currency string
|
||||
*/
|
||||
function formatMoney(
|
||||
amountInCents: number,
|
||||
currency?: CurrencyConfig
|
||||
): string {
|
||||
return formatMoneyUtil(amountInCents, currency ?? DEFAULT_CURRENCY_CONFIG)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an amount from one currency to another using exchange rates.
|
||||
*
|
||||
* @param amountInCents - Amount in cents in the source currency
|
||||
* @param fromRate - Exchange rate of the source currency
|
||||
* @param toRate - Exchange rate of the target currency
|
||||
* @returns Converted amount in cents
|
||||
*/
|
||||
function convertCurrency(
|
||||
amountInCents: number,
|
||||
fromRate: number,
|
||||
toRate: number
|
||||
): number {
|
||||
if (fromRate === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.round((amountInCents / fromRate) * toRate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a currency by its ISO code.
|
||||
*
|
||||
* @param code - The ISO 4217 currency code (e.g. "USD")
|
||||
* @returns The matching Currency, or undefined
|
||||
*/
|
||||
function findCurrencyByCode(code: string): Currency | undefined {
|
||||
return currencies.value.find(
|
||||
(c) => c.code.toUpperCase() === code.toUpperCase()
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
currencies,
|
||||
setCurrencies,
|
||||
formatMoney,
|
||||
convertCurrency,
|
||||
findCurrencyByCode,
|
||||
}
|
||||
}
|
||||
92
resources/scripts-v2/composables/use-date.ts
Normal file
92
resources/scripts-v2/composables/use-date.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
formatDate as formatDateUtil,
|
||||
relativeTime as relativeTimeUtil,
|
||||
parseDate as parseDateUtil,
|
||||
DEFAULT_DATE_FORMAT,
|
||||
DEFAULT_DATETIME_FORMAT,
|
||||
} from '../utils/format-date'
|
||||
import type { Locale } from 'date-fns'
|
||||
|
||||
export interface UseDateReturn {
|
||||
formatDate: (
|
||||
date: Date | string | number,
|
||||
formatStr?: string,
|
||||
options?: { locale?: Locale }
|
||||
) => string
|
||||
formatDateTime: (
|
||||
date: Date | string | number,
|
||||
options?: { locale?: Locale }
|
||||
) => string
|
||||
relativeTime: (
|
||||
date: Date | string | number,
|
||||
options?: { addSuffix?: boolean; locale?: Locale }
|
||||
) => string
|
||||
parseDate: (date: Date | string | number) => Date | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for date formatting and parsing using date-fns.
|
||||
* Provides convenient wrappers around utility functions for use within Vue components.
|
||||
*/
|
||||
export function useDate(): UseDateReturn {
|
||||
/**
|
||||
* Format a date using a format pattern.
|
||||
*
|
||||
* @param date - A Date object, ISO string, or timestamp
|
||||
* @param formatStr - date-fns format pattern (default: 'yyyy-MM-dd')
|
||||
* @param options - Optional locale
|
||||
* @returns Formatted date string, or empty string if invalid
|
||||
*/
|
||||
function formatDate(
|
||||
date: Date | string | number,
|
||||
formatStr: string = DEFAULT_DATE_FORMAT,
|
||||
options?: { locale?: Locale }
|
||||
): string {
|
||||
return formatDateUtil(date, formatStr, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date with date and time.
|
||||
*
|
||||
* @param date - A Date object, ISO string, or timestamp
|
||||
* @param options - Optional locale
|
||||
* @returns Formatted datetime string
|
||||
*/
|
||||
function formatDateTime(
|
||||
date: Date | string | number,
|
||||
options?: { locale?: Locale }
|
||||
): string {
|
||||
return formatDateUtil(date, DEFAULT_DATETIME_FORMAT, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable relative time string (e.g. "3 days ago").
|
||||
*
|
||||
* @param date - A Date object, ISO string, or timestamp
|
||||
* @param options - Optional addSuffix and locale settings
|
||||
* @returns Relative time string
|
||||
*/
|
||||
function relativeTime(
|
||||
date: Date | string | number,
|
||||
options?: { addSuffix?: boolean; locale?: Locale }
|
||||
): string {
|
||||
return relativeTimeUtil(date, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date value into a Date object.
|
||||
*
|
||||
* @param date - A Date object, ISO string, or timestamp
|
||||
* @returns Parsed Date or null
|
||||
*/
|
||||
function parseDate(date: Date | string | number): Date | null {
|
||||
return parseDateUtil(date)
|
||||
}
|
||||
|
||||
return {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
relativeTime,
|
||||
parseDate,
|
||||
}
|
||||
}
|
||||
113
resources/scripts-v2/composables/use-dialog.ts
Normal file
113
resources/scripts-v2/composables/use-dialog.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { DeepReadonly, Ref } from 'vue'
|
||||
import { DIALOG_VARIANT } from '../config/constants'
|
||||
import type { DialogVariant } from '../config/constants'
|
||||
|
||||
export interface DialogState {
|
||||
active: boolean
|
||||
title: string
|
||||
message: string
|
||||
variant: DialogVariant
|
||||
yesLabel: string
|
||||
noLabel: string
|
||||
hideNoButton: boolean
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export interface OpenConfirmOptions {
|
||||
title: string
|
||||
message: string
|
||||
variant?: DialogVariant
|
||||
yesLabel?: string
|
||||
noLabel?: string
|
||||
hideNoButton?: boolean
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
export interface UseDialogReturn {
|
||||
dialogState: DeepReadonly<Ref<DialogState>>
|
||||
openConfirm: (options: OpenConfirmOptions) => Promise<boolean>
|
||||
closeDialog: () => void
|
||||
confirmDialog: () => void
|
||||
cancelDialog: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: DialogState = {
|
||||
active: false,
|
||||
title: '',
|
||||
message: '',
|
||||
variant: DIALOG_VARIANT.DANGER,
|
||||
yesLabel: 'Yes',
|
||||
noLabel: 'No',
|
||||
hideNoButton: false,
|
||||
data: null,
|
||||
}
|
||||
|
||||
const dialogState = ref<DialogState>({ ...DEFAULT_STATE })
|
||||
|
||||
let resolvePromise: ((value: boolean) => void) | null = null
|
||||
|
||||
/**
|
||||
* Composable for managing confirmation dialogs.
|
||||
* Returns a promise that resolves to true (confirmed) or false (cancelled).
|
||||
*/
|
||||
export function useDialog(): UseDialogReturn {
|
||||
/**
|
||||
* Open a confirmation dialog and await the user's response.
|
||||
*
|
||||
* @param options - Dialog configuration
|
||||
* @returns Promise that resolves to true if confirmed, false if cancelled
|
||||
*/
|
||||
function openConfirm(options: OpenConfirmOptions): Promise<boolean> {
|
||||
dialogState.value = {
|
||||
active: true,
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
variant: options.variant ?? DIALOG_VARIANT.DANGER,
|
||||
yesLabel: options.yesLabel ?? 'Yes',
|
||||
noLabel: options.noLabel ?? 'No',
|
||||
hideNoButton: options.hideNoButton ?? false,
|
||||
data: options.data ?? null,
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
}
|
||||
|
||||
function confirmDialog(): void {
|
||||
if (resolvePromise) {
|
||||
resolvePromise(true)
|
||||
resolvePromise = null
|
||||
}
|
||||
resetDialog()
|
||||
}
|
||||
|
||||
function cancelDialog(): void {
|
||||
if (resolvePromise) {
|
||||
resolvePromise(false)
|
||||
resolvePromise = null
|
||||
}
|
||||
resetDialog()
|
||||
}
|
||||
|
||||
function closeDialog(): void {
|
||||
cancelDialog()
|
||||
}
|
||||
|
||||
function resetDialog(): void {
|
||||
dialogState.value.active = false
|
||||
|
||||
setTimeout(() => {
|
||||
dialogState.value = { ...DEFAULT_STATE }
|
||||
}, 300)
|
||||
}
|
||||
|
||||
return {
|
||||
dialogState: readonly(dialogState),
|
||||
openConfirm,
|
||||
closeDialog,
|
||||
confirmDialog,
|
||||
cancelDialog,
|
||||
}
|
||||
}
|
||||
105
resources/scripts-v2/composables/use-filters.ts
Normal file
105
resources/scripts-v2/composables/use-filters.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface UseFiltersOptions<T extends Record<string, unknown>> {
|
||||
initialFilters: T
|
||||
debounceMs?: number
|
||||
onChange?: (filters: T) => void
|
||||
}
|
||||
|
||||
export interface UseFiltersReturn<T extends Record<string, unknown>> {
|
||||
filters: T
|
||||
activeFilterCount: Ref<number>
|
||||
setFilter: <K extends keyof T>(key: K, value: T[K]) => void
|
||||
clearFilters: () => void
|
||||
clearFilter: <K extends keyof T>(key: K) => void
|
||||
getFiltersSnapshot: () => T
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing list filter state with optional debounced apply.
|
||||
*
|
||||
* @param options - Configuration including initial filter values, debounce delay, and change callback
|
||||
*/
|
||||
export function useFilters<T extends Record<string, unknown>>(
|
||||
options: UseFiltersOptions<T>
|
||||
): UseFiltersReturn<T> {
|
||||
const { initialFilters, debounceMs = 300, onChange } = options
|
||||
|
||||
const filters = reactive<T>({ ...initialFilters }) as T
|
||||
|
||||
const activeFilterCount = ref<number>(0)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function updateActiveFilterCount(): void {
|
||||
let count = 0
|
||||
const initialKeys = Object.keys(initialFilters) as Array<keyof T>
|
||||
|
||||
for (const key of initialKeys) {
|
||||
const current = filters[key]
|
||||
const initial = initialFilters[key]
|
||||
|
||||
if (current !== initial && current !== '' && current !== null && current !== undefined) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
activeFilterCount.value = count
|
||||
}
|
||||
|
||||
function debouncedApply(): void {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
updateActiveFilterCount()
|
||||
onChange?.({ ...filters })
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
function setFilter<K extends keyof T>(key: K, value: T[K]): void {
|
||||
filters[key] = value
|
||||
debouncedApply()
|
||||
}
|
||||
|
||||
function clearFilter<K extends keyof T>(key: K): void {
|
||||
filters[key] = initialFilters[key]
|
||||
debouncedApply()
|
||||
}
|
||||
|
||||
function clearFilters(): void {
|
||||
const keys = Object.keys(initialFilters) as Array<keyof T>
|
||||
for (const key of keys) {
|
||||
filters[key] = initialFilters[key]
|
||||
}
|
||||
updateActiveFilterCount()
|
||||
onChange?.({ ...filters })
|
||||
}
|
||||
|
||||
function getFiltersSnapshot(): T {
|
||||
return { ...filters }
|
||||
}
|
||||
|
||||
// Watch for external reactive changes and apply debounce
|
||||
watch(
|
||||
() => ({ ...filters }),
|
||||
() => {
|
||||
debouncedApply()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Initialize the active count
|
||||
updateActiveFilterCount()
|
||||
|
||||
return {
|
||||
filters,
|
||||
activeFilterCount,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
clearFilter,
|
||||
getFiltersSnapshot,
|
||||
}
|
||||
}
|
||||
101
resources/scripts-v2/composables/use-modal.ts
Normal file
101
resources/scripts-v2/composables/use-modal.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import type { DeepReadonly, Ref, ComputedRef } from 'vue'
|
||||
import type { ModalSize } from '../config/constants'
|
||||
|
||||
export interface ModalState {
|
||||
active: boolean
|
||||
componentName: string
|
||||
title: string
|
||||
content: string
|
||||
id: string
|
||||
size: ModalSize
|
||||
data: unknown
|
||||
refreshData: (() => void) | null
|
||||
variant: string
|
||||
}
|
||||
|
||||
export interface OpenModalOptions {
|
||||
componentName: string
|
||||
title?: string
|
||||
content?: string
|
||||
id?: string
|
||||
size?: ModalSize
|
||||
data?: unknown
|
||||
refreshData?: () => void
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export interface UseModalReturn {
|
||||
modalState: DeepReadonly<Ref<ModalState>>
|
||||
isEdit: ComputedRef<boolean>
|
||||
openModal: (options: OpenModalOptions) => void
|
||||
closeModal: () => void
|
||||
resetModalData: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: ModalState = {
|
||||
active: false,
|
||||
componentName: '',
|
||||
title: '',
|
||||
content: '',
|
||||
id: '',
|
||||
size: 'md',
|
||||
data: null,
|
||||
refreshData: null,
|
||||
variant: '',
|
||||
}
|
||||
|
||||
const modalState = ref<ModalState>({ ...DEFAULT_STATE })
|
||||
|
||||
/**
|
||||
* Composable for managing a global modal.
|
||||
* Supports opening modals by component name with props, and tracks edit vs create mode.
|
||||
*/
|
||||
export function useModal(): UseModalReturn {
|
||||
const isEdit = computed<boolean>(() => modalState.value.id !== '')
|
||||
|
||||
/**
|
||||
* Open a modal with the specified options.
|
||||
*
|
||||
* @param options - Modal configuration including component name and optional props
|
||||
*/
|
||||
function openModal(options: OpenModalOptions): void {
|
||||
modalState.value = {
|
||||
active: true,
|
||||
componentName: options.componentName,
|
||||
title: options.title ?? '',
|
||||
content: options.content ?? '',
|
||||
id: options.id ?? '',
|
||||
size: options.size ?? 'md',
|
||||
data: options.data ?? null,
|
||||
refreshData: options.refreshData ?? null,
|
||||
variant: options.variant ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal with a brief delay for animation.
|
||||
*/
|
||||
function closeModal(): void {
|
||||
modalState.value.active = false
|
||||
|
||||
setTimeout(() => {
|
||||
resetModalData()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset modal data back to defaults.
|
||||
*/
|
||||
function resetModalData(): void {
|
||||
modalState.value = { ...DEFAULT_STATE }
|
||||
}
|
||||
|
||||
return {
|
||||
modalState: readonly(modalState),
|
||||
isEdit,
|
||||
openModal,
|
||||
closeModal,
|
||||
resetModalData,
|
||||
}
|
||||
}
|
||||
83
resources/scripts-v2/composables/use-notification.ts
Normal file
83
resources/scripts-v2/composables/use-notification.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { DeepReadonly, Ref } from 'vue'
|
||||
import { NOTIFICATION_TYPE } from '../config/constants'
|
||||
import type { NotificationType } from '../config/constants'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: NotificationType
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UseNotificationReturn {
|
||||
notifications: DeepReadonly<Ref<Notification[]>>
|
||||
showSuccess: (message: string) => void
|
||||
showError: (message: string) => void
|
||||
showInfo: (message: string) => void
|
||||
showWarning: (message: string) => void
|
||||
showNotification: (type: NotificationType, message: string) => void
|
||||
hideNotification: (id: string) => void
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
const notifications = ref<Notification[]>([])
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a notification.
|
||||
*/
|
||||
function generateId(): string {
|
||||
return (
|
||||
Math.random().toString(36) + Date.now().toString(36)
|
||||
).substring(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing application-wide notifications.
|
||||
* Provides typed helpers for success, error, info, and warning notifications.
|
||||
*/
|
||||
export function useNotification(): UseNotificationReturn {
|
||||
function showNotification(type: NotificationType, message: string): void {
|
||||
notifications.value.push({
|
||||
id: generateId(),
|
||||
type,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
function showSuccess(message: string): void {
|
||||
showNotification(NOTIFICATION_TYPE.SUCCESS, message)
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
showNotification(NOTIFICATION_TYPE.ERROR, message)
|
||||
}
|
||||
|
||||
function showInfo(message: string): void {
|
||||
showNotification(NOTIFICATION_TYPE.INFO, message)
|
||||
}
|
||||
|
||||
function showWarning(message: string): void {
|
||||
showNotification(NOTIFICATION_TYPE.WARNING, message)
|
||||
}
|
||||
|
||||
function hideNotification(id: string): void {
|
||||
notifications.value = notifications.value.filter(
|
||||
(notification) => notification.id !== id
|
||||
)
|
||||
}
|
||||
|
||||
function clearAll(): void {
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
notifications: readonly(notifications),
|
||||
showSuccess,
|
||||
showError,
|
||||
showInfo,
|
||||
showWarning,
|
||||
showNotification,
|
||||
hideNotification,
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
91
resources/scripts-v2/composables/use-pagination.ts
Normal file
91
resources/scripts-v2/composables/use-pagination.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { PAGINATION_DEFAULTS } from '../config/constants'
|
||||
|
||||
export interface UsePaginationOptions {
|
||||
initialPage?: number
|
||||
initialLimit?: number
|
||||
}
|
||||
|
||||
export interface UsePaginationReturn {
|
||||
page: Ref<number>
|
||||
limit: Ref<number>
|
||||
totalCount: Ref<number>
|
||||
totalPages: ComputedRef<number>
|
||||
hasNextPage: ComputedRef<boolean>
|
||||
hasPrevPage: ComputedRef<boolean>
|
||||
nextPage: () => void
|
||||
prevPage: () => void
|
||||
goToPage: (target: number) => void
|
||||
setTotalCount: (count: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing pagination state.
|
||||
* Tracks page, limit, total count, and provides navigation helpers.
|
||||
*
|
||||
* @param options - Optional initial page and limit values
|
||||
*/
|
||||
export function usePagination(
|
||||
options?: UsePaginationOptions
|
||||
): UsePaginationReturn {
|
||||
const initialPage = options?.initialPage ?? PAGINATION_DEFAULTS.PAGE
|
||||
const initialLimit = options?.initialLimit ?? PAGINATION_DEFAULTS.LIMIT
|
||||
|
||||
const page = ref<number>(initialPage)
|
||||
const limit = ref<number>(initialLimit)
|
||||
const totalCount = ref<number>(0)
|
||||
|
||||
const totalPages = computed<number>(() => {
|
||||
if (totalCount.value === 0 || limit.value === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.ceil(totalCount.value / limit.value)
|
||||
})
|
||||
|
||||
const hasNextPage = computed<boolean>(() => page.value < totalPages.value)
|
||||
|
||||
const hasPrevPage = computed<boolean>(() => page.value > 1)
|
||||
|
||||
function nextPage(): void {
|
||||
if (hasNextPage.value) {
|
||||
page.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage(): void {
|
||||
if (hasPrevPage.value) {
|
||||
page.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(target: number): void {
|
||||
if (target >= 1 && target <= totalPages.value) {
|
||||
page.value = target
|
||||
}
|
||||
}
|
||||
|
||||
function setTotalCount(count: number): void {
|
||||
totalCount.value = count
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
page.value = initialPage
|
||||
totalCount.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
goToPage,
|
||||
setTotalCount,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
160
resources/scripts-v2/composables/use-permissions.ts
Normal file
160
resources/scripts-v2/composables/use-permissions.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { ABILITIES } from '../config/abilities'
|
||||
import type { Ability } from '../config/abilities'
|
||||
|
||||
export interface UserAbility {
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UsePermissionsReturn {
|
||||
currentAbilities: Ref<UserAbility[]>
|
||||
isOwner: Ref<boolean>
|
||||
isSuperAdmin: Ref<boolean>
|
||||
setAbilities: (abilities: UserAbility[]) => void
|
||||
setOwner: (owner: boolean) => void
|
||||
setSuperAdmin: (superAdmin: boolean) => void
|
||||
hasAbility: (ability: Ability | Ability[]) => boolean
|
||||
hasAllAbilities: (abilities: Ability[]) => boolean
|
||||
canViewCustomer: ComputedRef<boolean>
|
||||
canCreateCustomer: ComputedRef<boolean>
|
||||
canEditCustomer: ComputedRef<boolean>
|
||||
canDeleteCustomer: ComputedRef<boolean>
|
||||
canViewInvoice: ComputedRef<boolean>
|
||||
canCreateInvoice: ComputedRef<boolean>
|
||||
canEditInvoice: ComputedRef<boolean>
|
||||
canDeleteInvoice: ComputedRef<boolean>
|
||||
canViewEstimate: ComputedRef<boolean>
|
||||
canCreateEstimate: ComputedRef<boolean>
|
||||
canEditEstimate: ComputedRef<boolean>
|
||||
canDeleteEstimate: ComputedRef<boolean>
|
||||
canViewPayment: ComputedRef<boolean>
|
||||
canCreatePayment: ComputedRef<boolean>
|
||||
canEditPayment: ComputedRef<boolean>
|
||||
canDeletePayment: ComputedRef<boolean>
|
||||
canViewExpense: ComputedRef<boolean>
|
||||
canCreateExpense: ComputedRef<boolean>
|
||||
canEditExpense: ComputedRef<boolean>
|
||||
canDeleteExpense: ComputedRef<boolean>
|
||||
canViewDashboard: ComputedRef<boolean>
|
||||
canViewFinancialReport: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
const currentAbilities = ref<UserAbility[]>([])
|
||||
const isOwner = ref<boolean>(false)
|
||||
const isSuperAdmin = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* Composable for managing user permissions and abilities.
|
||||
* Extracted from the user store's hasAbilities/hasAllAbilities logic,
|
||||
* with typed convenience computed properties for common CRUD checks.
|
||||
*/
|
||||
export function usePermissions(): UsePermissionsReturn {
|
||||
function setAbilities(abilities: UserAbility[]): void {
|
||||
currentAbilities.value = abilities
|
||||
}
|
||||
|
||||
function setOwner(owner: boolean): void {
|
||||
isOwner.value = owner
|
||||
}
|
||||
|
||||
function setSuperAdmin(superAdmin: boolean): void {
|
||||
isSuperAdmin.value = superAdmin
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has a given ability or any of the given abilities.
|
||||
* A wildcard ability ('*') grants access to everything.
|
||||
*
|
||||
* @param ability - A single ability string or array of abilities
|
||||
* @returns True if the user has the ability
|
||||
*/
|
||||
function hasAbility(ability: Ability | Ability[]): boolean {
|
||||
return !!currentAbilities.value.find((ab) => {
|
||||
if (ab.name === '*') return true
|
||||
|
||||
if (typeof ability === 'string') {
|
||||
return ab.name === ability
|
||||
}
|
||||
|
||||
return !!ability.find((p) => ab.name === p)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has ALL of the given abilities.
|
||||
*
|
||||
* @param abilities - Array of abilities that must all be present
|
||||
* @returns True if the user has every listed ability
|
||||
*/
|
||||
function hasAllAbilities(abilities: Ability[]): boolean {
|
||||
return abilities.every((ability) =>
|
||||
currentAbilities.value.some(
|
||||
(ab) => ab.name === '*' || ab.name === ability
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Convenience computed properties for common permission checks
|
||||
const canViewCustomer = computed<boolean>(() => hasAbility(ABILITIES.VIEW_CUSTOMER))
|
||||
const canCreateCustomer = computed<boolean>(() => hasAbility(ABILITIES.CREATE_CUSTOMER))
|
||||
const canEditCustomer = computed<boolean>(() => hasAbility(ABILITIES.EDIT_CUSTOMER))
|
||||
const canDeleteCustomer = computed<boolean>(() => hasAbility(ABILITIES.DELETE_CUSTOMER))
|
||||
|
||||
const canViewInvoice = computed<boolean>(() => hasAbility(ABILITIES.VIEW_INVOICE))
|
||||
const canCreateInvoice = computed<boolean>(() => hasAbility(ABILITIES.CREATE_INVOICE))
|
||||
const canEditInvoice = computed<boolean>(() => hasAbility(ABILITIES.EDIT_INVOICE))
|
||||
const canDeleteInvoice = computed<boolean>(() => hasAbility(ABILITIES.DELETE_INVOICE))
|
||||
|
||||
const canViewEstimate = computed<boolean>(() => hasAbility(ABILITIES.VIEW_ESTIMATE))
|
||||
const canCreateEstimate = computed<boolean>(() => hasAbility(ABILITIES.CREATE_ESTIMATE))
|
||||
const canEditEstimate = computed<boolean>(() => hasAbility(ABILITIES.EDIT_ESTIMATE))
|
||||
const canDeleteEstimate = computed<boolean>(() => hasAbility(ABILITIES.DELETE_ESTIMATE))
|
||||
|
||||
const canViewPayment = computed<boolean>(() => hasAbility(ABILITIES.VIEW_PAYMENT))
|
||||
const canCreatePayment = computed<boolean>(() => hasAbility(ABILITIES.CREATE_PAYMENT))
|
||||
const canEditPayment = computed<boolean>(() => hasAbility(ABILITIES.EDIT_PAYMENT))
|
||||
const canDeletePayment = computed<boolean>(() => hasAbility(ABILITIES.DELETE_PAYMENT))
|
||||
|
||||
const canViewExpense = computed<boolean>(() => hasAbility(ABILITIES.VIEW_EXPENSE))
|
||||
const canCreateExpense = computed<boolean>(() => hasAbility(ABILITIES.CREATE_EXPENSE))
|
||||
const canEditExpense = computed<boolean>(() => hasAbility(ABILITIES.EDIT_EXPENSE))
|
||||
const canDeleteExpense = computed<boolean>(() => hasAbility(ABILITIES.DELETE_EXPENSE))
|
||||
|
||||
const canViewDashboard = computed<boolean>(() => hasAbility(ABILITIES.DASHBOARD))
|
||||
const canViewFinancialReport = computed<boolean>(() => hasAbility(ABILITIES.VIEW_FINANCIAL_REPORT))
|
||||
|
||||
return {
|
||||
currentAbilities,
|
||||
isOwner,
|
||||
isSuperAdmin,
|
||||
setAbilities,
|
||||
setOwner,
|
||||
setSuperAdmin,
|
||||
hasAbility,
|
||||
hasAllAbilities,
|
||||
canViewCustomer,
|
||||
canCreateCustomer,
|
||||
canEditCustomer,
|
||||
canDeleteCustomer,
|
||||
canViewInvoice,
|
||||
canCreateInvoice,
|
||||
canEditInvoice,
|
||||
canDeleteInvoice,
|
||||
canViewEstimate,
|
||||
canCreateEstimate,
|
||||
canEditEstimate,
|
||||
canDeleteEstimate,
|
||||
canViewPayment,
|
||||
canCreatePayment,
|
||||
canEditPayment,
|
||||
canDeletePayment,
|
||||
canViewExpense,
|
||||
canCreateExpense,
|
||||
canEditExpense,
|
||||
canDeleteExpense,
|
||||
canViewDashboard,
|
||||
canViewFinancialReport,
|
||||
}
|
||||
}
|
||||
49
resources/scripts-v2/composables/use-sidebar.ts
Normal file
49
resources/scripts-v2/composables/use-sidebar.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import * as ls from '../utils/local-storage'
|
||||
import { LS_KEYS } from '../config/constants'
|
||||
|
||||
export interface UseSidebarReturn {
|
||||
isCollapsed: Ref<boolean>
|
||||
isSidebarOpen: Ref<boolean>
|
||||
toggleCollapse: () => void
|
||||
setSidebarVisibility: (visible: boolean) => void
|
||||
}
|
||||
|
||||
const isCollapsed = ref<boolean>(
|
||||
ls.get<string>(LS_KEYS.SIDEBAR_COLLAPSED) === 'true'
|
||||
)
|
||||
|
||||
const isSidebarOpen = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* Composable for managing sidebar state.
|
||||
* Handles both the mobile sidebar open/close and the desktop collapsed toggle.
|
||||
* Persists the collapsed state to localStorage.
|
||||
*/
|
||||
export function useSidebar(): UseSidebarReturn {
|
||||
/**
|
||||
* Toggle the sidebar collapsed/expanded state (desktop).
|
||||
* Persists the new value to localStorage.
|
||||
*/
|
||||
function toggleCollapse(): void {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
ls.set(LS_KEYS.SIDEBAR_COLLAPSED, isCollapsed.value ? 'true' : 'false')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the mobile sidebar visibility.
|
||||
*
|
||||
* @param visible - Whether the sidebar should be visible
|
||||
*/
|
||||
function setSidebarVisibility(visible: boolean): void {
|
||||
isSidebarOpen.value = visible
|
||||
}
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
isSidebarOpen,
|
||||
toggleCollapse,
|
||||
setSidebarVisibility,
|
||||
}
|
||||
}
|
||||
92
resources/scripts-v2/composables/use-theme.ts
Normal file
92
resources/scripts-v2/composables/use-theme.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { THEME, LS_KEYS } from '../config/constants'
|
||||
import type { Theme } from '../config/constants'
|
||||
import * as ls from '../utils/local-storage'
|
||||
|
||||
export interface UseThemeReturn {
|
||||
currentTheme: Ref<Theme>
|
||||
setTheme: (theme: Theme) => void
|
||||
applyTheme: (theme?: Theme) => void
|
||||
}
|
||||
|
||||
const currentTheme = ref<Theme>(
|
||||
(ls.get<string>(LS_KEYS.THEME) as Theme) ?? THEME.SYSTEM
|
||||
)
|
||||
|
||||
let mediaQueryCleanup: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Apply the correct data-theme attribute to the document element.
|
||||
*
|
||||
* @param theme - The theme to apply (light, dark, or system)
|
||||
*/
|
||||
function applyThemeToDocument(theme: Theme): void {
|
||||
const prefersDark =
|
||||
theme === THEME.DARK ||
|
||||
(theme === THEME.SYSTEM &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
if (prefersDark) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing the application theme (light/dark/system).
|
||||
* Extracted from TheSiteHeader. Listens for system preference changes
|
||||
* when set to "system" mode.
|
||||
*/
|
||||
export function useTheme(): UseThemeReturn {
|
||||
/**
|
||||
* Set and persist the current theme.
|
||||
*
|
||||
* @param theme - The theme to set
|
||||
*/
|
||||
function setTheme(theme: Theme): void {
|
||||
currentTheme.value = theme
|
||||
ls.set(LS_KEYS.THEME, theme)
|
||||
applyThemeToDocument(theme)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given or current theme to the document.
|
||||
*
|
||||
* @param theme - Optional theme override; uses currentTheme if not provided
|
||||
*/
|
||||
function applyTheme(theme?: Theme): void {
|
||||
applyThemeToDocument(theme ?? currentTheme.value)
|
||||
}
|
||||
|
||||
function handleSystemThemeChange(): void {
|
||||
if (currentTheme.value === THEME.SYSTEM) {
|
||||
applyThemeToDocument(THEME.SYSTEM)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyThemeToDocument(currentTheme.value)
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange)
|
||||
|
||||
mediaQueryCleanup = () => {
|
||||
mediaQuery.removeEventListener('change', handleSystemThemeChange)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mediaQueryCleanup) {
|
||||
mediaQueryCleanup()
|
||||
mediaQueryCleanup = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
currentTheme,
|
||||
setTheme,
|
||||
applyTheme,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user