Initialer Laravel Commit für BetiX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-04-04 18:01:50 +02:00
commit 0280278978
374 changed files with 65210 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import type { Appearance, ResolvedAppearance } from '@/types';
export type { Appearance, ResolvedAppearance };
export type UseAppearanceReturn = {
appearance: Ref<Appearance>;
resolvedAppearance: ComputedRef<ResolvedAppearance>;
updateAppearance: (value: Appearance) => void;
};
export function updateTheme(value: Appearance): void {
if (typeof window === 'undefined') {
return;
}
if (value === 'system') {
const mediaQueryList = window.matchMedia(
'(prefers-color-scheme: dark)',
);
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
document.documentElement.classList.toggle(
'dark',
systemTheme === 'dark',
);
} else {
document.documentElement.classList.toggle('dark', value === 'dark');
}
}
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
}
const maxAge = days * 24 * 60 * 60;
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
};
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
};
const getStoredAppearance = () => {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem('appearance') as Appearance | null;
};
const prefersDark = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const handleSystemThemeChange = () => {
const currentAppearance = getStoredAppearance();
updateTheme(currentAppearance || 'system');
};
export function initializeTheme(): void {
if (typeof window === 'undefined') {
return;
}
// Initialize theme from saved preference or default to system...
const savedAppearance = getStoredAppearance();
updateTheme(savedAppearance || 'system');
// Set up system theme change listener...
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
}
const appearance = ref<Appearance>('system');
export function useAppearance(): UseAppearanceReturn {
onMounted(() => {
const savedAppearance = localStorage.getItem(
'appearance',
) as Appearance | null;
if (savedAppearance) {
appearance.value = savedAppearance;
}
});
const resolvedAppearance = computed<ResolvedAppearance>(() => {
if (appearance.value === 'system') {
return prefersDark() ? 'dark' : 'light';
}
return appearance.value;
});
function updateAppearance(value: Appearance) {
appearance.value = value;
// Store in localStorage for client-side persistence...
localStorage.setItem('appearance', value);
// Store in cookie for SSR...
setCookie('appearance', value);
updateTheme(value);
}
return {
appearance,
resolvedAppearance,
updateAppearance,
};
}

View File

@@ -0,0 +1,59 @@
import type { InertiaLinkProps } from '@inertiajs/vue3';
import { usePage } from '@inertiajs/vue3';
import type { ComputedRef, DeepReadonly } from 'vue';
import { computed, readonly } from 'vue';
import { toUrl } from '@/lib/utils';
export type UseCurrentUrlReturn = {
currentUrl: DeepReadonly<ComputedRef<string>>;
isCurrentUrl: (
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
) => boolean;
whenCurrentUrl: <T, F = null>(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: T,
ifFalse?: F,
) => T | F;
};
const page = usePage();
const currentUrlReactive = computed(
() => new URL(page.url, window?.location.origin).pathname,
);
export function useCurrentUrl(): UseCurrentUrlReturn {
function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
) {
const urlToCompare = currentUrl ?? currentUrlReactive.value;
const urlString = toUrl(urlToCheck);
if (!urlString.startsWith('http')) {
return urlString === urlToCompare;
}
try {
const absoluteUrl = new URL(urlString);
return absoluteUrl.pathname === urlToCompare;
} catch {
return false;
}
}
function whenCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: any,
ifFalse: any = null,
) {
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
}
return {
currentUrl: readonly(currentUrlReactive),
isCurrentUrl,
whenCurrentUrl,
};
}

View File

@@ -0,0 +1,18 @@
export type UseInitialsReturn = {
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 useInitials(): UseInitialsReturn {
return { getInitials };
}

View File

@@ -0,0 +1,90 @@
import { ref } from 'vue';
export type ToastType = 'green' | 'magenta' | 'blue' | 'gold' | 'red';
export interface NotificationData {
type: ToastType;
title: string;
desc: string;
icon: string;
}
interface Toast extends NotificationData {
id: number;
active: boolean;
flyingOut: boolean;
progress: number;
}
interface HistoryItem extends NotificationData {
id: number;
timestamp: Date;
read: boolean;
}
const toasts = ref<Toast[]>([]);
const history = ref<HistoryItem[]>([]);
const unreadCount = ref(0);
let toastId = 0;
export function useNotifications() {
const notify = (data: NotificationData) => {
// Add to toasts (popup)
const id = toastId++;
const toast: Toast = { id, ...data, active: false, flyingOut: false, progress: 100 };
toasts.value.push(toast);
// Animation logic for toast
setTimeout(() => {
const t = toasts.value.find(x => x.id === id);
if(t) t.active = true;
// Trigger lucide icons update if needed in component
}, 50);
const interval = setInterval(() => {
const t = toasts.value.find(x => x.id === id);
if (!t) { clearInterval(interval); return; }
t.progress -= 0.5;
if (t.progress <= 0) { clearInterval(interval); closeToast(id); }
}, 40);
// Add to history
history.value.unshift({
id: Date.now() + Math.random(),
...data,
timestamp: new Date(),
read: false
});
unreadCount.value++;
};
const closeToast = (id: number) => {
const t = toasts.value.find(x => x.id === id);
if (t) {
t.flyingOut = true;
setTimeout(() => {
toasts.value = toasts.value.filter(x => x.id !== id);
}, 600);
}
};
const markAllRead = () => {
history.value.forEach(n => n.read = true);
unreadCount.value = 0;
};
const clearAll = () => {
history.value = [];
unreadCount.value = 0;
};
return {
toasts,
history,
unreadCount,
notify,
closeToast,
markAllRead,
clearAll
};
}

View File

@@ -0,0 +1,129 @@
import { ref, onMounted } from 'vue';
export type UsePrimaryColorReturn = {
primaryColor: ReturnType<typeof ref<string>>;
updatePrimaryColor: (value: string) => void;
};
const STORAGE_KEY = 'primaryColor';
function setCookie(name: string, value: string, days = 365) {
if (typeof document === 'undefined') return;
const maxAge = days * 24 * 60 * 60;
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
}
function getStoredPrimaryColor(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(STORAGE_KEY);
}
function parseColorToRGB(color: string): { r: number; g: number; b: number } | null {
// Supports #rgb, #rrggbb, rgb(), hsl()
color = color.trim().toLowerCase();
// Hex
if (color.startsWith('#')) {
let hex = color.slice(1);
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('');
}
if (hex.length === 6) {
const num = parseInt(hex, 16);
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
}
}
// rgb/rgba
if (color.startsWith('rgb')) {
const m = color.match(/rgba?\(([^)]+)\)/);
if (m) {
const [r, g, b] = m[1]
.split(',')
.slice(0, 3)
.map((v) => parseFloat(v.trim()));
return { r, g, b };
}
}
// hsl/hsla
if (color.startsWith('hsl')) {
const m = color.match(/hsla?\(([^)]+)\)/);
if (m) {
const [hStr, sStr, lStr] = m[1].split(',').map((v) => v.trim());
const h = parseFloat(hStr);
const s = parseFloat(sStr) / 100;
const l = parseFloat(lStr) / 100;
// convert HSL to RGB
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m2 = l - c / 2;
let r1 = 0, g1 = 0, b1 = 0;
if (0 <= h && h < 60) [r1, g1, b1] = [c, x, 0];
else if (60 <= h && h < 120) [r1, g1, b1] = [x, c, 0];
else if (120 <= h && h < 180) [r1, g1, b1] = [0, c, x];
else if (180 <= h && h < 240) [r1, g1, b1] = [0, x, c];
else if (240 <= h && h < 300) [r1, g1, b1] = [x, 0, c];
else [r1, g1, b1] = [c, 0, x];
return {
r: Math.round((r1 + m2) * 255),
g: Math.round((g1 + m2) * 255),
b: Math.round((b1 + m2) * 255),
};
}
}
return null;
}
function getContrastColor(bg: string): '#000000' | '#FFFFFF' {
const rgb = parseColorToRGB(bg) || { r: 0, g: 0, b: 0 };
// Relative luminance
const srgb = [rgb.r, rgb.g, rgb.b].map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
const L = 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
return L > 0.179 ? '#000000' : '#FFFFFF';
}
export function applyPrimaryColor(value: string): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// Apply to global primary variables used by Tailwind theme tokens
root.style.setProperty('--primary', value);
root.style.setProperty('--primary-foreground', getContrastColor(value));
// Align commonly used accent variables in user layout to the same main color
root.style.setProperty('--magenta', value);
root.style.setProperty('--sidebar-primary', value);
root.style.setProperty('--sidebar-primary-foreground', getContrastColor(value));
}
export function initializePrimaryColor(): void {
const saved = getStoredPrimaryColor();
if (saved) applyPrimaryColor(saved);
}
const primaryColorRef = ref<string>(getStoredPrimaryColor() || '#ff007a');
export function usePrimaryColor(): UsePrimaryColorReturn {
onMounted(() => {
const saved = getStoredPrimaryColor();
if (saved) {
primaryColorRef.value = saved;
}
});
function updatePrimaryColor(value: string) {
primaryColorRef.value = value;
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, value);
}
setCookie(STORAGE_KEY, value);
applyPrimaryColor(value);
}
return { primaryColor: primaryColorRef, updatePrimaryColor };
}

View File

@@ -0,0 +1,120 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = {
qrCodeSvg: Ref<string | null>;
manualSetupKey: Ref<string | null>;
recoveryCodesList: Ref<string[]>;
errors: Ref<string[]>;
hasSetupData: ComputedRef<boolean>;
clearSetupData: () => void;
clearErrors: () => void;
clearTwoFactorAuthData: () => void;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
return response.json();
};
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const hasSetupData = computed<boolean>(
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
);
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
const fetchQrCode = async (): Promise<void> => {
try {
const { svg } = await fetchJson<{ svg: string; url: string }>(
qrCode.url(),
);
qrCodeSvg.value = svg;
} catch {
errors.value.push('Failed to fetch QR code');
qrCodeSvg.value = null;
}
};
const fetchSetupKey = async (): Promise<void> => {
try {
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
secretKey.url(),
);
manualSetupKey.value = key;
} catch {
errors.value.push('Failed to fetch a setup key');
manualSetupKey.value = null;
}
};
const clearSetupData = (): void => {
manualSetupKey.value = null;
qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
recoveryCodesList.value = [];
};
const fetchRecoveryCodes = async (): Promise<void> => {
try {
clearErrors();
recoveryCodesList.value = await fetchJson<string[]>(
recoveryCodes.url(),
);
} catch {
errors.value.push('Failed to fetch recovery codes');
recoveryCodesList.value = [];
}
};
const fetchSetupData = async (): Promise<void> => {
try {
clearErrors();
await Promise.all([fetchQrCode(), fetchSetupKey()]);
} catch {
qrCodeSvg.value = null;
manualSetupKey.value = null;
}
};
return {
qrCodeSvg,
manualSetupKey,
recoveryCodesList,
errors,
hasSetupData,
clearSetupData,
clearErrors,
clearTwoFactorAuthData,
fetchQrCode,
fetchSetupKey,
fetchSetupData,
fetchRecoveryCodes,
};
};

View File

@@ -0,0 +1,180 @@
import { ref } from 'vue';
import { csrfFetch } from '@/utils/csrfFetch';
export interface VaultBalances {
balance: string;
vault_balance: string;
vault_balances: Record<string, string>;
currency: string | null;
}
export function useVault() {
const loading = ref(false);
const error = ref<string | null>(null);
const balances = ref<VaultBalances | null>(null);
const transfers = ref<any>(null);
const pinRequired = ref(false);
const lockedUntil = ref<string | null>(null);
const sessionPin = ref<string | null>(null);
async function load(perPage = 10) {
loading.value = true;
error.value = null;
try {
const resp = await fetch(`/api/wallet/vault?per_page=${perPage}`, {
headers: { 'Accept': 'application/json' }
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = await resp.json();
balances.value = {
balance: json.balance,
vault_balance: json.vault_balance,
vault_balances: json.vault_balances ?? { BTX: json.vault_balance },
currency: json.currency,
};
transfers.value = json.transfers;
} catch (e: any) {
error.value = e?.message || 'Failed to load vault';
} finally {
loading.value = false;
}
}
function uuidv4(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return (crypto as any).randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
async function deposit(amount: string, currency = 'BTX', pin?: string) {
return doAction('/api/wallet/vault/deposit', amount, currency, pin);
}
async function withdraw(amount: string, currency = 'BTX', pin?: string) {
return doAction('/api/wallet/vault/withdraw', amount, currency, pin);
}
async function doAction(url: string, amount: string, currency: string, pin?: string) {
loading.value = true;
error.value = null;
pinRequired.value = false;
lockedUntil.value = null;
try {
const usePin = pin ?? sessionPin.value;
if (!usePin) {
pinRequired.value = true;
throw new Error('PIN required');
}
const body = { amount, currency, pin: usePin, idempotency_key: uuidv4() };
const resp = await csrfFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(body),
});
if (resp.status === 423) {
const j = await resp.json().catch(() => ({}));
pinRequired.value = true;
lockedUntil.value = j?.locked_until || null;
throw new Error(j?.message || 'PIN required');
}
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
throw new Error(j?.message || `HTTP ${resp.status}`);
}
const json = await resp.json();
if (json?.balances) {
balances.value = {
balance: json.balances.balance,
vault_balance: json.balances.vault_balance,
vault_balances: json.balances.vault_balances ?? { BTX: json.balances.vault_balance },
currency: balances.value?.currency || 'BTX',
};
} else {
await load();
}
return json;
} catch (e: any) {
error.value = e?.message || 'Vault action failed';
throw e;
} finally {
loading.value = false;
}
}
async function verifyPin(pin: string) {
error.value = null;
try {
const resp = await csrfFetch('/api/wallet/vault/pin/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ pin }),
});
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
if (resp.status === 423) lockedUntil.value = j?.locked_until || null;
throw new Error(j?.message || `HTTP ${resp.status}`);
}
sessionPin.value = pin;
pinRequired.value = false;
lockedUntil.value = null;
return await resp.json();
} catch (e: any) {
error.value = e?.message || 'PIN verification failed';
throw e;
}
}
function clearSessionPin() {
sessionPin.value = null;
}
async function setPin(pin: string, current_pin?: string) {
error.value = null;
const payload: any = { pin };
if (current_pin) payload.current_pin = current_pin;
const resp = await csrfFetch('/api/wallet/vault/pin/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
throw new Error(j?.message || `HTTP ${resp.status}`);
}
sessionPin.value = pin;
return await resp.json();
}
return {
loading,
error,
balances,
transfers,
pinRequired,
lockedUntil,
load,
deposit,
withdraw,
verifyPin,
setPin,
clearSessionPin,
};
}