first commit
No files matched your search
|
After Width: | Height: | Size: 653 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 718 KiB |
|
After Width: | Height: | Size: 981 KiB |
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 685 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 545 KiB |
|
After Width: | Height: | Size: 669 KiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 666 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 493 KiB |
@@ -0,0 +1,80 @@
|
||||
import type { LinkComponentBaseProps } from '@inertiajs/core';
|
||||
import { page } from '@inertiajs/svelte';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
|
||||
export type CurrentUrlState = {
|
||||
readonly currentUrl: string;
|
||||
isCurrentUrl: (
|
||||
urlToCheck: NonNullable<LinkComponentBaseProps['href']>,
|
||||
currentUrl: string,
|
||||
) => boolean;
|
||||
isCurrentOrParentUrl: (
|
||||
urlToCheck: NonNullable<LinkComponentBaseProps['href']>,
|
||||
currentUrl: string,
|
||||
) => boolean;
|
||||
whenCurrentUrl: <TIfTrue, TIfFalse>(
|
||||
urlToCheck: NonNullable<LinkComponentBaseProps['href']>,
|
||||
currentUrl: string,
|
||||
ifTrue: TIfTrue,
|
||||
ifFalse: TIfFalse,
|
||||
) => TIfTrue | TIfFalse;
|
||||
};
|
||||
|
||||
export function currentUrlState(): CurrentUrlState {
|
||||
const currentUrl = $derived.by(() => {
|
||||
const origin =
|
||||
typeof window === 'undefined'
|
||||
? 'http://localhost'
|
||||
: window.location.origin;
|
||||
|
||||
try {
|
||||
return new URL(page.url, origin).pathname;
|
||||
} catch {
|
||||
return page.url;
|
||||
}
|
||||
});
|
||||
|
||||
function isCurrentUrl(
|
||||
urlToCheck: NonNullable<LinkComponentBaseProps['href']>,
|
||||
current: string,
|
||||
): boolean {
|
||||
const resolved = toUrl(urlToCheck);
|
||||
|
||||
if (typeof resolved !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return current === resolved;
|
||||
}
|
||||
|
||||
function isCurrentOrParentUrl(
|
||||
urlToCheck: NonNullable<LinkComponentBaseProps['href']>,
|
||||
current: string,
|
||||
): boolean {
|
||||
const resolved = toUrl(urlToCheck);
|
||||
|
||||
if (typeof resolved !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return current.startsWith(resolved);
|
||||
}
|
||||
|
||||
function whenCurrentUrl<TIfTrue, TIfFalse>(
|
||||
urlToCheck: NonNullable<LinkComponentBaseProps['href']>,
|
||||
current: string,
|
||||
ifTrue: TIfTrue,
|
||||
ifFalse: TIfFalse,
|
||||
): TIfTrue | TIfFalse {
|
||||
return isCurrentUrl(urlToCheck, current) ? ifTrue : ifFalse;
|
||||
}
|
||||
|
||||
return {
|
||||
get currentUrl() {
|
||||
return currentUrl;
|
||||
},
|
||||
isCurrentUrl,
|
||||
isCurrentOrParentUrl,
|
||||
whenCurrentUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { router } from '@inertiajs/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { FlashToast } from '@/types/ui';
|
||||
|
||||
export function initializeFlashToast(): void {
|
||||
router.on('flash', (event) => {
|
||||
const flash = (event as CustomEvent).detail?.flash;
|
||||
const data = flash?.toast as FlashToast | undefined;
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast[data.type](data.message);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type InitialsApi = {
|
||||
getInitials: (fullName?: string) => string;
|
||||
};
|
||||
|
||||
export function getInitials(fullName?: string): string {
|
||||
if (!fullName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const names = fullName.trim().split(' ');
|
||||
|
||||
if (names.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (names.length === 1) {
|
||||
return names[0].charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function initialsApi(): InitialsApi {
|
||||
return { getInitials };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Appearance, ResolvedAppearance } from '@/types';
|
||||
|
||||
export type { Appearance, ResolvedAppearance };
|
||||
|
||||
export type ThemeState = {
|
||||
appearance: {
|
||||
value: Appearance;
|
||||
};
|
||||
resolvedAppearance: () => ResolvedAppearance;
|
||||
updateAppearance: (value: Appearance) => void;
|
||||
};
|
||||
|
||||
const appearance = $state<{ value: Appearance }>({ value: 'system' });
|
||||
|
||||
let themeChangeMediaQuery: MediaQueryList | null = null;
|
||||
|
||||
const prefersDark = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const isDarkMode = (value: Appearance): boolean => {
|
||||
return value === 'dark' || (value === 'system' && prefersDark());
|
||||
};
|
||||
|
||||
const getResolvedAppearance = (): ResolvedAppearance => {
|
||||
return isDarkMode(appearance.value) ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const setCookie = (name: string, value: string, days = 365): void => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
};
|
||||
|
||||
const applyTheme = (value: Appearance): void => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDark = isDarkMode(value);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const getStoredAppearance = (): Appearance => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'system';
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem('appearance');
|
||||
|
||||
return stored === 'light' || stored === 'dark' || stored === 'system'
|
||||
? stored
|
||||
: 'system';
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = (): void => {
|
||||
applyTheme(appearance.value);
|
||||
};
|
||||
|
||||
const detachThemeChangeListener = (): void => {
|
||||
if (!themeChangeMediaQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
themeChangeMediaQuery.removeEventListener(
|
||||
'change',
|
||||
handleSystemThemeChange,
|
||||
);
|
||||
themeChangeMediaQuery = null;
|
||||
};
|
||||
|
||||
export function initializeTheme(): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!localStorage.getItem('appearance')) {
|
||||
localStorage.setItem('appearance', 'system');
|
||||
setCookie('appearance', 'system');
|
||||
}
|
||||
|
||||
appearance.value = getStoredAppearance();
|
||||
applyTheme(appearance.value);
|
||||
|
||||
detachThemeChangeListener();
|
||||
themeChangeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
themeChangeMediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||
|
||||
return detachThemeChangeListener;
|
||||
}
|
||||
|
||||
export function updateAppearance(value: Appearance): void {
|
||||
appearance.value = value;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('appearance', value);
|
||||
}
|
||||
|
||||
setCookie('appearance', value);
|
||||
applyTheme(value);
|
||||
}
|
||||
|
||||
export function themeState(): ThemeState {
|
||||
return {
|
||||
appearance,
|
||||
resolvedAppearance: getResolvedAppearance,
|
||||
updateAppearance,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useHttp } from '@inertiajs/svelte';
|
||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||
|
||||
type TwoFactorAuthState = {
|
||||
qrCodeSvg: string | null;
|
||||
manualSetupKey: string | null;
|
||||
recoveryCodesList: string[];
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type TwoFactorAuthStateApi = {
|
||||
state: TwoFactorAuthState;
|
||||
hasSetupData: () => boolean;
|
||||
clearSetupData: () => void;
|
||||
clearErrors: () => void;
|
||||
clearTwoFactorAuthData: () => void;
|
||||
fetchQrCode: () => Promise<void>;
|
||||
fetchSetupKey: () => Promise<void>;
|
||||
fetchSetupData: () => Promise<void>;
|
||||
fetchRecoveryCodes: () => Promise<void>;
|
||||
};
|
||||
|
||||
const state = $state<TwoFactorAuthState>({
|
||||
qrCodeSvg: null,
|
||||
manualSetupKey: null,
|
||||
recoveryCodesList: [],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const hasSetupData = (): boolean =>
|
||||
state.qrCodeSvg !== null && state.manualSetupKey !== null;
|
||||
|
||||
export function twoFactorAuthState(): TwoFactorAuthStateApi {
|
||||
const http = useHttp();
|
||||
|
||||
const fetchQrCode = async (): Promise<void> => {
|
||||
try {
|
||||
const { svg } = (await http.submit(qrCode())) as {
|
||||
svg: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
state.qrCodeSvg = svg;
|
||||
} catch {
|
||||
state.errors = [...state.errors, 'Failed to fetch QR code'];
|
||||
state.qrCodeSvg = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupKey = async (): Promise<void> => {
|
||||
try {
|
||||
const { secretKey: key } = (await http.submit(secretKey())) as {
|
||||
secretKey: string;
|
||||
};
|
||||
|
||||
state.manualSetupKey = key;
|
||||
} catch {
|
||||
state.errors = [...state.errors, 'Failed to fetch a setup key'];
|
||||
state.manualSetupKey = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearErrors = (): void => {
|
||||
state.errors = [];
|
||||
};
|
||||
|
||||
const clearSetupData = (): void => {
|
||||
state.manualSetupKey = null;
|
||||
state.qrCodeSvg = null;
|
||||
clearErrors();
|
||||
};
|
||||
|
||||
const clearTwoFactorAuthData = (): void => {
|
||||
clearSetupData();
|
||||
state.recoveryCodesList = [];
|
||||
clearErrors();
|
||||
};
|
||||
|
||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
state.recoveryCodesList = (await http.submit(
|
||||
recoveryCodes(),
|
||||
)) as string[];
|
||||
} catch {
|
||||
state.errors = [...state.errors, 'Failed to fetch recovery codes'];
|
||||
state.recoveryCodesList = [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupData = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
||||
} catch {
|
||||
state.qrCodeSvg = null;
|
||||
state.manualSetupKey = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
hasSetupData,
|
||||
clearSetupData,
|
||||
clearErrors,
|
||||
clearTwoFactorAuthData,
|
||||
fetchQrCode,
|
||||
fetchSetupKey,
|
||||
fetchSetupData,
|
||||
fetchRecoveryCodes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LinkComponentBaseProps } from '@inertiajs/core';
|
||||
import { clsx } from 'clsx';
|
||||
import type { ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function toUrl(
|
||||
href: NonNullable<LinkComponentBaseProps['href']>,
|
||||
): string {
|
||||
return typeof href === 'string' ? href : href.url;
|
||||
}
|
||||