mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
refactor: consolidate codebase — remove archived code, deduplicate packages, standardize middleware
- Delete 17 server-archived/ directories (consolidated into apps/api/) - Delete apps-archived/ (clock, wisekeep) and services-archived/ (it-landing, ollama-metrics-proxy) - Fix type safety: replace all `any` casts in cross-app-queries.ts with proper Local* types - Remove duplicate shared-auth-stores package (identical copy of shared-auth-ui/stores/) - Remove duplicate theme store from shared-stores (canonical version in shared-theme) - Migrate memoro-server rate-limiter to shared-hono/rateLimitMiddleware - Migrate uload-server JWT auth + error handler to shared-hono (authMiddleware, errorHandler) - Migrate arcade-server error handling to shared-hono - Merge shared-profile-ui and shared-app-onboarding into shared-ui - Unify /clock route into /times/clock, remove redirect stubs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7ee57b7afd
commit
d8ce4eaf34
309 changed files with 172 additions and 21667 deletions
|
|
@ -188,6 +188,25 @@ export type { QuickInputItem, QuickAction, CreatePreview, InputBarSettings } fro
|
|||
// Pages
|
||||
export { default as AppsPage } from './pages/AppsPage.svelte';
|
||||
export { default as OfflinePage } from './pages/OfflinePage.svelte';
|
||||
export { default as ProfilePage } from './pages/ProfilePage.svelte';
|
||||
export type { UserProfile, ProfileActions } from './pages/profile-types';
|
||||
|
||||
// Onboarding
|
||||
export { createAppOnboardingStore } from './onboarding/create-app-onboarding.svelte';
|
||||
export { default as MiniOnboardingModal } from './onboarding/MiniOnboardingModal.svelte';
|
||||
export type {
|
||||
AppOnboardingOption,
|
||||
AppOnboardingStepType,
|
||||
AppOnboardingStepBase,
|
||||
AppOnboardingSelectStep,
|
||||
AppOnboardingToggleStep,
|
||||
AppOnboardingInfoStep,
|
||||
AppOnboardingStep,
|
||||
AppOnboardingConfig,
|
||||
AppOnboardingPreferences,
|
||||
AppOnboardingStore,
|
||||
MiniOnboardingModalProps,
|
||||
} from './onboarding/types';
|
||||
|
||||
// Charts - Statistics Visualization
|
||||
export {
|
||||
|
|
|
|||
208
packages/shared-ui/src/onboarding/MiniOnboardingModal.svelte
Normal file
208
packages/shared-ui/src/onboarding/MiniOnboardingModal.svelte
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<script lang="ts">
|
||||
import type {
|
||||
AppOnboardingStore,
|
||||
AppOnboardingSelectStep,
|
||||
AppOnboardingToggleStep,
|
||||
} from './types';
|
||||
|
||||
interface Props {
|
||||
store: AppOnboardingStore;
|
||||
appName: string;
|
||||
appEmoji?: string;
|
||||
}
|
||||
|
||||
let { store, appName, appEmoji = '🚀' }: Props = $props();
|
||||
|
||||
function handleNext() {
|
||||
if (store.isLastStep) {
|
||||
store.complete();
|
||||
} else {
|
||||
store.next();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
store.prev();
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
store.skip();
|
||||
}
|
||||
|
||||
function selectOption(stepId: string, optionId: string) {
|
||||
store.setPreference(stepId, optionId);
|
||||
}
|
||||
|
||||
function toggleOption(stepId: string) {
|
||||
const currentValue = store.preferences[stepId] ?? false;
|
||||
store.setPreference(stepId, !currentValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<!-- Modal Container -->
|
||||
<div
|
||||
class="bg-surface-elevated-2 rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200 border border-border"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="border-b px-5 py-4 flex-shrink-0">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-xl">{appEmoji}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="font-semibold text-sm">{appName} einrichten</h1>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Schritt {store.currentStep + 1} von {store.totalSteps}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleSkip}
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step dots -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#each Array(store.totalSteps) as _, i}
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all duration-300 {i <= store.currentStep
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'} {i === store.currentStep ? 'w-6' : 'w-1.5'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Step content -->
|
||||
<main class="flex-1 overflow-y-auto px-5 py-6">
|
||||
{#if store.currentStepConfig}
|
||||
{@const step = store.currentStepConfig}
|
||||
|
||||
<div class="text-center">
|
||||
<!-- Question -->
|
||||
<h2 class="text-lg font-bold mb-2">{step.question}</h2>
|
||||
{#if step.description}
|
||||
<p class="text-sm text-muted-foreground mb-5">{step.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Step content based on type -->
|
||||
<div class="max-w-sm mx-auto">
|
||||
{#if step.type === 'select'}
|
||||
{@const selectStep = step as AppOnboardingSelectStep}
|
||||
<div class="space-y-2">
|
||||
{#each selectStep.options as option}
|
||||
{@const isSelected = store.preferences[step.id] === option.id}
|
||||
<button
|
||||
onclick={() => selectOption(step.id, option.id)}
|
||||
class="w-full p-3 rounded-xl border-2 transition-all text-left {isSelected
|
||||
? 'border-primary bg-primary/15 shadow-sm shadow-primary/20'
|
||||
: 'border-border hover:border-primary/50 bg-surface-elevated-1'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if option.emoji}
|
||||
<span class="text-xl">{option.emoji}</span>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-sm">{option.label}</p>
|
||||
{#if option.description}
|
||||
<p class="text-xs text-muted-foreground">{option.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isSelected}
|
||||
<svg
|
||||
class="w-5 h-5 text-primary flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if step.type === 'toggle'}
|
||||
{@const toggleStep = step as AppOnboardingToggleStep}
|
||||
{@const isEnabled = store.preferences[step.id] ?? toggleStep.defaultValue ?? false}
|
||||
<button
|
||||
onclick={() => toggleOption(step.id)}
|
||||
class="w-full p-4 rounded-xl border-2 transition-all {isEnabled
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-surface-elevated-1'}"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-sm">
|
||||
{isEnabled
|
||||
? toggleStep.enabledLabel || 'Aktiviert'
|
||||
: toggleStep.disabledLabel || 'Deaktiviert'}
|
||||
</span>
|
||||
<div
|
||||
class="relative w-11 h-6 rounded-full transition-colors {isEnabled
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform {isEnabled
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{:else if step.type === 'info'}
|
||||
{#if step.bullets}
|
||||
<ul class="text-left space-y-2">
|
||||
{#each step.bullets as bullet}
|
||||
<li class="flex items-start gap-2 text-sm">
|
||||
<span class="text-primary mt-0.5">•</span>
|
||||
<span class="text-muted-foreground">{bullet}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Footer with navigation -->
|
||||
<footer class="border-t px-5 py-3 flex-shrink-0">
|
||||
<div class="flex justify-between">
|
||||
{#if store.isFirstStep}
|
||||
<div></div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handlePrev}
|
||||
class="px-4 py-2 text-sm border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleNext}
|
||||
disabled={store.saving}
|
||||
class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
{#if store.saving}
|
||||
Speichern...
|
||||
{:else if store.isLastStep}
|
||||
Fertig
|
||||
{:else}
|
||||
Weiter
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import type {
|
||||
AppOnboardingConfig,
|
||||
AppOnboardingStore,
|
||||
AppOnboardingPreferences,
|
||||
AppOnboardingStep,
|
||||
} from './types';
|
||||
|
||||
const ONBOARDING_COMPLETED_KEY = 'onboarding_completed';
|
||||
const ONBOARDING_PREFERENCES_KEY = 'onboarding_preferences';
|
||||
|
||||
/**
|
||||
* Create an app-specific onboarding store
|
||||
*
|
||||
* This factory creates a store that:
|
||||
* - Checks if onboarding was already completed (via deviceSettings)
|
||||
* - Manages step navigation and preference collection
|
||||
* - Saves completion state and preferences to deviceSettings
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createAppOnboardingStore } from '@manacore/shared-app-onboarding';
|
||||
*
|
||||
* const calendarOnboarding = createAppOnboardingStore({
|
||||
* appId: 'calendar',
|
||||
* steps: [
|
||||
* {
|
||||
* id: 'weekStart',
|
||||
* type: 'select',
|
||||
* question: 'Wann beginnt deine Woche?',
|
||||
* emoji: '📅',
|
||||
* options: [
|
||||
* { id: 'monday', label: 'Montag', emoji: '1️⃣' },
|
||||
* { id: 'sunday', label: 'Sonntag', emoji: '7️⃣' },
|
||||
* ],
|
||||
* defaultValue: 'monday',
|
||||
* },
|
||||
* ],
|
||||
* userSettings,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createAppOnboardingStore(config: AppOnboardingConfig): AppOnboardingStore {
|
||||
const { appId, steps, userSettings, onComplete, onSkip } = config;
|
||||
|
||||
// State
|
||||
let currentStep = $state(0);
|
||||
let preferences = $state<AppOnboardingPreferences>({});
|
||||
let saving = $state(false);
|
||||
let completed = $state(false);
|
||||
|
||||
// Initialize preferences with default values
|
||||
for (const step of steps) {
|
||||
if (step.type === 'select' && step.defaultValue !== undefined) {
|
||||
preferences[step.id] = step.defaultValue;
|
||||
} else if (step.type === 'toggle' && step.defaultValue !== undefined) {
|
||||
preferences[step.id] = step.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Derived values
|
||||
const totalSteps = $derived(steps.length);
|
||||
const isFirstStep = $derived(currentStep === 0);
|
||||
const isLastStep = $derived(currentStep === steps.length - 1);
|
||||
const progress = $derived(((currentStep + 1) / steps.length) * 100);
|
||||
const currentStepConfig = $derived<AppOnboardingStep | undefined>(steps[currentStep]);
|
||||
|
||||
/**
|
||||
* Check if onboarding was already completed
|
||||
*/
|
||||
function checkCompleted(): boolean {
|
||||
const deviceAppSettings = userSettings.getDeviceAppSettings();
|
||||
return deviceAppSettings[ONBOARDING_COMPLETED_KEY] === true;
|
||||
}
|
||||
|
||||
// Derived: should show modal
|
||||
const shouldShow = $derived(!completed && !checkCompleted() && userSettings.loaded);
|
||||
|
||||
/**
|
||||
* Go to next step
|
||||
*/
|
||||
function next(): void {
|
||||
if (currentStep < steps.length - 1) {
|
||||
currentStep++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous step
|
||||
*/
|
||||
function prev(): void {
|
||||
if (currentStep > 0) {
|
||||
currentStep--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to specific step
|
||||
*/
|
||||
function goToStep(index: number): void {
|
||||
if (index >= 0 && index < steps.length) {
|
||||
currentStep = index;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a preference value
|
||||
*/
|
||||
function setPreference(key: string, value: unknown): void {
|
||||
preferences = { ...preferences, [key]: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the onboarding and save preferences
|
||||
*/
|
||||
async function complete(): Promise<void> {
|
||||
saving = true;
|
||||
try {
|
||||
// Save to deviceSettings
|
||||
await userSettings.updateDeviceAppSettings({
|
||||
[ONBOARDING_COMPLETED_KEY]: true,
|
||||
[ONBOARDING_PREFERENCES_KEY]: preferences,
|
||||
...preferences, // Also spread preferences at top level for easy access
|
||||
});
|
||||
|
||||
completed = true;
|
||||
|
||||
// Call completion callback
|
||||
if (onComplete) {
|
||||
await onComplete(preferences);
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the onboarding entirely
|
||||
*/
|
||||
async function skip(): Promise<void> {
|
||||
saving = true;
|
||||
try {
|
||||
// Mark as completed but don't save preferences
|
||||
await userSettings.updateDeviceAppSettings({
|
||||
[ONBOARDING_COMPLETED_KEY]: true,
|
||||
onboarding_skipped: true,
|
||||
});
|
||||
|
||||
completed = true;
|
||||
|
||||
// Call skip callback
|
||||
if (onSkip) {
|
||||
await onSkip();
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset onboarding (for testing/debugging)
|
||||
*/
|
||||
async function reset(): Promise<void> {
|
||||
saving = true;
|
||||
try {
|
||||
// Remove onboarding flags from deviceSettings
|
||||
await userSettings.updateDeviceAppSettings({
|
||||
[ONBOARDING_COMPLETED_KEY]: false,
|
||||
onboarding_skipped: false,
|
||||
});
|
||||
|
||||
completed = false;
|
||||
currentStep = 0;
|
||||
|
||||
// Reset preferences to defaults
|
||||
preferences = {};
|
||||
for (const step of steps) {
|
||||
if (step.type === 'select' && step.defaultValue !== undefined) {
|
||||
preferences[step.id] = step.defaultValue;
|
||||
} else if (step.type === 'toggle' && step.defaultValue !== undefined) {
|
||||
preferences[step.id] = step.defaultValue;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get shouldShow() {
|
||||
return shouldShow;
|
||||
},
|
||||
get currentStep() {
|
||||
return currentStep;
|
||||
},
|
||||
get totalSteps() {
|
||||
return totalSteps;
|
||||
},
|
||||
get isFirstStep() {
|
||||
return isFirstStep;
|
||||
},
|
||||
get isLastStep() {
|
||||
return isLastStep;
|
||||
},
|
||||
get progress() {
|
||||
return progress;
|
||||
},
|
||||
get currentStepConfig() {
|
||||
return currentStepConfig;
|
||||
},
|
||||
get preferences() {
|
||||
return preferences;
|
||||
},
|
||||
get saving() {
|
||||
return saving;
|
||||
},
|
||||
get appId() {
|
||||
return appId;
|
||||
},
|
||||
get steps() {
|
||||
return steps;
|
||||
},
|
||||
|
||||
next,
|
||||
prev,
|
||||
goToStep,
|
||||
setPreference,
|
||||
complete,
|
||||
skip,
|
||||
reset,
|
||||
checkCompleted,
|
||||
};
|
||||
}
|
||||
157
packages/shared-ui/src/onboarding/types.ts
Normal file
157
packages/shared-ui/src/onboarding/types.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type { UserSettingsStore } from '@manacore/shared-theme';
|
||||
|
||||
/**
|
||||
* Option for a selection-based onboarding step
|
||||
*/
|
||||
export interface AppOnboardingOption {
|
||||
/** Unique identifier for this option */
|
||||
id: string;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Optional emoji icon */
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step type determines the UI component used
|
||||
*/
|
||||
export type AppOnboardingStepType = 'select' | 'toggle' | 'info';
|
||||
|
||||
/**
|
||||
* Base step configuration
|
||||
*/
|
||||
export interface AppOnboardingStepBase {
|
||||
/** Unique identifier for this step */
|
||||
id: string;
|
||||
/** Question or title for this step */
|
||||
question: string;
|
||||
/** Optional subtitle/description */
|
||||
description?: string;
|
||||
/** Emoji icon for the step header */
|
||||
emoji?: string;
|
||||
/** Gradient colors for the icon background */
|
||||
gradient?: { from: string; to: string };
|
||||
/** Whether this step can be skipped */
|
||||
skippable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection step - user picks one option
|
||||
*/
|
||||
export interface AppOnboardingSelectStep extends AppOnboardingStepBase {
|
||||
type: 'select';
|
||||
/** Available options */
|
||||
options: AppOnboardingOption[];
|
||||
/** Default selected value */
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle step - user enables/disables something
|
||||
*/
|
||||
export interface AppOnboardingToggleStep extends AppOnboardingStepBase {
|
||||
type: 'toggle';
|
||||
/** Default toggle state */
|
||||
defaultValue?: boolean;
|
||||
/** Label when enabled */
|
||||
enabledLabel?: string;
|
||||
/** Label when disabled */
|
||||
disabledLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Info step - just shows information, no input
|
||||
*/
|
||||
export interface AppOnboardingInfoStep extends AppOnboardingStepBase {
|
||||
type: 'info';
|
||||
/** Bullet points to display */
|
||||
bullets?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all step types
|
||||
*/
|
||||
export type AppOnboardingStep =
|
||||
| AppOnboardingSelectStep
|
||||
| AppOnboardingToggleStep
|
||||
| AppOnboardingInfoStep;
|
||||
|
||||
/**
|
||||
* Configuration for creating an app onboarding store
|
||||
*/
|
||||
export interface AppOnboardingConfig {
|
||||
/** App identifier (e.g., 'calendar', 'todo', 'chat') */
|
||||
appId: string;
|
||||
/** Steps to show in the onboarding */
|
||||
steps: AppOnboardingStep[];
|
||||
/** User settings store instance (for deviceSettings access) */
|
||||
userSettings: UserSettingsStore;
|
||||
/** Optional callback when onboarding completes */
|
||||
onComplete?: (preferences: Record<string, unknown>) => void | Promise<void>;
|
||||
/** Optional callback when onboarding is skipped */
|
||||
onSkip?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collected preferences from completed steps
|
||||
*/
|
||||
export type AppOnboardingPreferences = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* App onboarding store interface
|
||||
*/
|
||||
export interface AppOnboardingStore {
|
||||
/** Whether the onboarding modal should be shown */
|
||||
readonly shouldShow: boolean;
|
||||
/** Current step index (0-based) */
|
||||
readonly currentStep: number;
|
||||
/** Total number of steps */
|
||||
readonly totalSteps: number;
|
||||
/** Whether on the first step */
|
||||
readonly isFirstStep: boolean;
|
||||
/** Whether on the last step */
|
||||
readonly isLastStep: boolean;
|
||||
/** Progress percentage (0-100) */
|
||||
readonly progress: number;
|
||||
/** Current step configuration */
|
||||
readonly currentStepConfig: AppOnboardingStep | undefined;
|
||||
/** Collected preferences so far */
|
||||
readonly preferences: AppOnboardingPreferences;
|
||||
/** Whether currently saving */
|
||||
readonly saving: boolean;
|
||||
/** App ID */
|
||||
readonly appId: string;
|
||||
/** All step configurations */
|
||||
readonly steps: AppOnboardingStep[];
|
||||
|
||||
/** Go to next step */
|
||||
next: () => void;
|
||||
/** Go to previous step */
|
||||
prev: () => void;
|
||||
/** Go to specific step */
|
||||
goToStep: (index: number) => void;
|
||||
/** Set a preference value */
|
||||
setPreference: (key: string, value: unknown) => void;
|
||||
/** Complete the onboarding and save preferences */
|
||||
complete: () => Promise<void>;
|
||||
/** Skip the onboarding entirely */
|
||||
skip: () => Promise<void>;
|
||||
/** Reset onboarding (for testing/debugging) */
|
||||
reset: () => Promise<void>;
|
||||
/** Check if onboarding was completed (reads from deviceSettings) */
|
||||
checkCompleted: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the MiniOnboardingModal component
|
||||
*/
|
||||
export interface MiniOnboardingModalProps {
|
||||
/** The onboarding store instance */
|
||||
store: AppOnboardingStore;
|
||||
/** App name for display */
|
||||
appName: string;
|
||||
/** Optional app icon/emoji */
|
||||
appEmoji?: string;
|
||||
}
|
||||
539
packages/shared-ui/src/pages/ProfilePage.svelte
Normal file
539
packages/shared-ui/src/pages/ProfilePage.svelte
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
<script lang="ts">
|
||||
import type { UserProfile, ProfileActions } from './profile-types';
|
||||
|
||||
interface Props {
|
||||
/** User profile data */
|
||||
user: UserProfile;
|
||||
/** App name for the page title */
|
||||
appName: string;
|
||||
/** Profile actions */
|
||||
actions?: ProfileActions;
|
||||
/** Page title */
|
||||
pageTitle?: string;
|
||||
/** Account info section title */
|
||||
accountInfoTitle?: string;
|
||||
/** Account actions section title */
|
||||
actionsTitle?: string;
|
||||
// i18n labels
|
||||
emailLabel?: string;
|
||||
nameLabel?: string;
|
||||
memberSinceLabel?: string;
|
||||
lastLoginLabel?: string;
|
||||
roleLabel?: string;
|
||||
editProfileLabel?: string;
|
||||
changePasswordLabel?: string;
|
||||
logoutLabel?: string;
|
||||
deleteAccountLabel?: string;
|
||||
deleteAccountWarning?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
user,
|
||||
appName,
|
||||
actions,
|
||||
pageTitle = 'Profil',
|
||||
accountInfoTitle = 'Konto-Informationen',
|
||||
actionsTitle = 'Aktionen',
|
||||
emailLabel = 'E-Mail',
|
||||
nameLabel = 'Name',
|
||||
memberSinceLabel = 'Mitglied seit',
|
||||
lastLoginLabel = 'Letzter Login',
|
||||
roleLabel = 'Rolle',
|
||||
editProfileLabel = 'Profil bearbeiten',
|
||||
changePasswordLabel = 'Passwort ändern',
|
||||
logoutLabel = 'Abmelden',
|
||||
deleteAccountLabel = 'Konto löschen',
|
||||
deleteAccountWarning = 'Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
}: Props = $props();
|
||||
|
||||
// Get display name
|
||||
const displayName = $derived(() => {
|
||||
if (user.displayName) return user.displayName;
|
||||
if (user.firstName && user.lastName) return `${user.firstName} ${user.lastName}`;
|
||||
if (user.firstName) return user.firstName;
|
||||
return null;
|
||||
});
|
||||
|
||||
// Get initials for avatar
|
||||
const initials = $derived(() => {
|
||||
if (user.firstName && user.lastName) {
|
||||
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
|
||||
}
|
||||
if (user.displayName) {
|
||||
const parts = user.displayName.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
}
|
||||
return user.displayName.substring(0, 2).toUpperCase();
|
||||
}
|
||||
return user.email.substring(0, 2).toUpperCase();
|
||||
});
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString?: string): string {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
// Format role
|
||||
function formatRole(role?: string): string {
|
||||
if (!role) return '-';
|
||||
const roles: Record<string, string> = {
|
||||
user: 'Benutzer',
|
||||
admin: 'Administrator',
|
||||
moderator: 'Moderator',
|
||||
};
|
||||
return roles[role] || role;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle} - {appName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="profile-page">
|
||||
<div class="profile-page__content">
|
||||
<div class="profile-page__container">
|
||||
<!-- Header -->
|
||||
<div class="profile-page__header">
|
||||
<!-- Avatar -->
|
||||
<div class="profile-page__avatar">
|
||||
{#if user.avatarUrl}
|
||||
<img src={user.avatarUrl} alt="Avatar" class="profile-page__avatar-image" />
|
||||
{:else}
|
||||
<span class="profile-page__avatar-initials">{initials()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h1 class="profile-page__title">{displayName() || user.email}</h1>
|
||||
{#if displayName()}
|
||||
<p class="profile-page__subtitle">{user.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Account Info Section -->
|
||||
<section class="profile-page__section">
|
||||
<h2 class="profile-page__section-title">{accountInfoTitle}</h2>
|
||||
|
||||
<div class="profile-page__card">
|
||||
<div class="profile-page__info-list">
|
||||
<!-- Email -->
|
||||
<div class="profile-page__info-item">
|
||||
<div class="profile-page__info-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="profile-page__info-content">
|
||||
<span class="profile-page__info-label">{emailLabel}</span>
|
||||
<span class="profile-page__info-value">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name (if available) -->
|
||||
{#if displayName()}
|
||||
<div class="profile-page__info-item">
|
||||
<div class="profile-page__info-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="profile-page__info-content">
|
||||
<span class="profile-page__info-label">{nameLabel}</span>
|
||||
<span class="profile-page__info-value">{displayName()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Role -->
|
||||
{#if user.role}
|
||||
<div class="profile-page__info-item">
|
||||
<div class="profile-page__info-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="profile-page__info-content">
|
||||
<span class="profile-page__info-label">{roleLabel}</span>
|
||||
<span class="profile-page__info-value">{formatRole(user.role)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Member Since -->
|
||||
{#if user.createdAt}
|
||||
<div class="profile-page__info-item">
|
||||
<div class="profile-page__info-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="profile-page__info-content">
|
||||
<span class="profile-page__info-label">{memberSinceLabel}</span>
|
||||
<span class="profile-page__info-value">{formatDate(user.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Last Login -->
|
||||
{#if user.lastLoginAt}
|
||||
<div class="profile-page__info-item">
|
||||
<div class="profile-page__info-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="profile-page__info-content">
|
||||
<span class="profile-page__info-label">{lastLoginLabel}</span>
|
||||
<span class="profile-page__info-value">{formatDate(user.lastLoginAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions Section -->
|
||||
{#if actions}
|
||||
<section class="profile-page__section">
|
||||
<h2 class="profile-page__section-title">{actionsTitle}</h2>
|
||||
|
||||
<div class="profile-page__actions">
|
||||
{#if actions.onEditProfile}
|
||||
<button class="profile-page__action-btn" onclick={actions.onEditProfile}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{editProfileLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if actions.onChangePassword}
|
||||
<button class="profile-page__action-btn" onclick={actions.onChangePassword}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{changePasswordLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if actions.onLogout}
|
||||
<button
|
||||
class="profile-page__action-btn profile-page__action-btn--secondary"
|
||||
onclick={actions.onLogout}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<span>{logoutLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if actions.onDeleteAccount}
|
||||
<button
|
||||
class="profile-page__action-btn profile-page__action-btn--danger"
|
||||
onclick={actions.onDeleteAccount}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span>{deleteAccountLabel}</span>
|
||||
</button>
|
||||
<p class="profile-page__warning">{deleteAccountWarning}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-page__content {
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-page__container {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 3rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-page__header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.profile-page__avatar {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.dark) .profile-page__avatar {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.profile-page__avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-page__avatar-initials {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary, 221 83% 53%));
|
||||
}
|
||||
|
||||
.profile-page__title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.profile-page__subtitle {
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-page__section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.profile-page__section-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.profile-page__card {
|
||||
padding: 1.25rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:global(.dark) .profile-page__card {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.profile-page__info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile-page__info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile-page__info-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.dark) .profile-page__info-icon {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.profile-page__info-icon svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: hsl(var(--primary, 221 83% 53%));
|
||||
}
|
||||
|
||||
.profile-page__info-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-page__info-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.profile-page__info-value {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.profile-page__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.profile-page__action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:global(.dark) .profile-page__action-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.profile-page__action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .profile-page__action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.profile-page__action-btn svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--primary, 221 83% 53%));
|
||||
}
|
||||
|
||||
.profile-page__action-btn--secondary {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
:global(.dark) .profile-page__action-btn--secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.profile-page__action-btn--secondary svg {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.profile-page__action-btn--danger {
|
||||
color: hsl(0 84% 60%);
|
||||
border-color: hsla(0, 84%, 60%, 0.2);
|
||||
}
|
||||
|
||||
.profile-page__action-btn--danger svg {
|
||||
color: hsl(0 84% 60%);
|
||||
}
|
||||
|
||||
.profile-page__action-btn--danger:hover {
|
||||
background: hsla(0, 84%, 60%, 0.1);
|
||||
border-color: hsla(0, 84%, 60%, 0.3);
|
||||
}
|
||||
|
||||
.profile-page__warning {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
37
packages/shared-ui/src/pages/profile-types.ts
Normal file
37
packages/shared-ui/src/pages/profile-types.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* User profile data for display in ProfilePage
|
||||
*/
|
||||
export interface UserProfile {
|
||||
/** User ID */
|
||||
id: string;
|
||||
/** User email */
|
||||
email: string;
|
||||
/** Display name (optional) */
|
||||
displayName?: string;
|
||||
/** First name (optional) */
|
||||
firstName?: string;
|
||||
/** Last name (optional) */
|
||||
lastName?: string;
|
||||
/** Avatar URL (optional) */
|
||||
avatarUrl?: string;
|
||||
/** Account creation date */
|
||||
createdAt?: string;
|
||||
/** Last login date */
|
||||
lastLoginAt?: string;
|
||||
/** User role */
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile action handlers
|
||||
*/
|
||||
export interface ProfileActions {
|
||||
/** Called when user wants to edit profile */
|
||||
onEditProfile?: () => void;
|
||||
/** Called when user wants to change password */
|
||||
onChangePassword?: () => void;
|
||||
/** Called when user wants to delete account */
|
||||
onDeleteAccount?: () => void;
|
||||
/** Called when user wants to logout */
|
||||
onLogout?: () => void;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue