mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
feat(manacore): nice-to-have UX polish
Onboarding auto-save (#18): - Profile name auto-saved via profileService when clicking "Weiter" - Non-blocking: save failure doesn't block onboarding flow - Name synced to parent via bindable prop Widget auto-refresh (#19): - New useAutoRefresh() utility with visibility-aware polling - Pauses refresh when tab is hidden, resumes on focus - Credits: every 60s, Tasks: every 30s, Calendar: every 60s - Silent refresh: doesn't show loading spinner on subsequent loads Remove debug logs (#24): - Removed console.log from AppSlider and auth SSO flow - Kept console.warn for API retry (useful for debugging) Dark mode on login (#20): - Sun/moon toggle button on auth pages (top-right corner) - Users can switch theme before logging in Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
12b3c4f0f3
commit
7da67febd1
9 changed files with 151 additions and 27 deletions
|
|
@ -21,8 +21,8 @@
|
|||
const statusLabels = APP_STATUS_LABELS.de;
|
||||
const labels = APP_SLIDER_LABELS.de;
|
||||
|
||||
function handleAppClick(app: AppItem, index: number) {
|
||||
console.log('Opening app:', app.name);
|
||||
function handleAppClick(_app: AppItem, _index: number) {
|
||||
// Navigation handled by AppSlider component
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* CalendarEventsWidget - Upcoming calendar events
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { calendarService, type CalendarEvent } from '$lib/api/services';
|
||||
import { useAutoRefresh } from '$lib/utils/autoRefresh';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
import { APP_URLS } from '@manacore/shared-branding';
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
const MAX_DISPLAY = 5;
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
if (data.length === 0) state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const result = await calendarService.getUpcomingEvents(7);
|
||||
|
|
@ -32,10 +32,11 @@
|
|||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
if (data.length === 0) {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
}
|
||||
|
||||
// Don't retry if service is unavailable (network error)
|
||||
const isServiceUnavailable = error?.includes('nicht erreichbar');
|
||||
if (!isServiceUnavailable && retryCount < 3) {
|
||||
retryCount++;
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
retrying = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
useAutoRefresh(load, 60000);
|
||||
|
||||
function formatEventTime(event: CalendarEvent): string {
|
||||
const start = new Date(event.startTime);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* CreditsWidget - Displays credit balance and stats
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { creditsService, type CreditBalance } from '$lib/api/credits';
|
||||
import { useAutoRefresh } from '$lib/utils/autoRefresh';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
let retrying = $state(false);
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
if (!data) state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
try {
|
||||
|
|
@ -23,14 +23,16 @@
|
|||
data = balance;
|
||||
state = 'success';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load credits';
|
||||
state = 'error';
|
||||
if (!data) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load credits';
|
||||
state = 'error';
|
||||
}
|
||||
} finally {
|
||||
retrying = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
useAutoRefresh(load, 60000);
|
||||
|
||||
function formatCredits(amount: number): string {
|
||||
return amount.toLocaleString('de-DE');
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* TasksTodayWidget - Today's tasks from Todo app
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { todoService, type Task } from '$lib/api/services';
|
||||
import { useAutoRefresh } from '$lib/utils/autoRefresh';
|
||||
import { APP_URLS } from '@manacore/shared-branding';
|
||||
import { format, isToday, isTomorrow, isPast } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
};
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
if (data.length === 0) state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const result = await todoService.getAllOpenTasks();
|
||||
|
|
@ -55,8 +55,10 @@
|
|||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
if (data.length === 0) {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
}
|
||||
|
||||
const isServiceUnavailable = error?.includes('nicht erreichbar');
|
||||
if (!isServiceUnavailable && retryCount < 3) {
|
||||
|
|
@ -68,13 +70,44 @@
|
|||
retrying = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
useAutoRefresh(load, 30000);
|
||||
|
||||
const displayedTasks = $derived((data || []).slice(0, MAX_DISPLAY));
|
||||
const remainingCount = $derived(Math.max(0, (data || []).length - MAX_DISPLAY));
|
||||
const completedCount = $derived((data || []).filter((t) => t.isCompleted).length);
|
||||
const totalCount = $derived((data || []).length);
|
||||
|
||||
// Track tasks being toggled (for optimistic UI)
|
||||
let togglingIds = $state<Set<string>>(new Set());
|
||||
|
||||
async function handleToggleComplete(e: MouseEvent, task: Task) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (togglingIds.has(task.id)) return;
|
||||
|
||||
// Optimistic update
|
||||
togglingIds = new Set([...togglingIds, task.id]);
|
||||
const wasCompleted = task.isCompleted;
|
||||
task.isCompleted = !wasCompleted;
|
||||
|
||||
const result = wasCompleted
|
||||
? await todoService.uncompleteTask(task.id)
|
||||
: await todoService.completeTask(task.id);
|
||||
|
||||
if (result.error) {
|
||||
// Revert on error
|
||||
task.isCompleted = wasCompleted;
|
||||
} else if (!wasCompleted) {
|
||||
// Task completed: remove from list after brief delay
|
||||
setTimeout(() => {
|
||||
data = data.filter((t) => t.id !== task.id);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
|
||||
}
|
||||
|
||||
function getSubtaskProgress(task: Task): string | null {
|
||||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
||||
const done = task.subtasks.filter((s) => s.isCompleted).length;
|
||||
|
|
@ -110,7 +143,9 @@
|
|||
<div class="space-y-1">
|
||||
{#each displayedTasks as task}
|
||||
<a
|
||||
href="{todoUrl}/task/{task.id}"
|
||||
href={todoUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<!-- Priority dot -->
|
||||
|
|
@ -120,10 +155,15 @@
|
|||
></div>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<div
|
||||
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border-2 {task.isCompleted
|
||||
<button
|
||||
onclick={(e) => handleToggleComplete(e, task)}
|
||||
class="flex h-4 w-4 flex-shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors {task.isCompleted
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground/40'}"
|
||||
: 'border-muted-foreground/40 hover:border-primary/60'} {togglingIds.has(task.id)
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
aria-label={task.isCompleted ? 'Als unerledigt markieren' : 'Als erledigt markieren'}
|
||||
disabled={togglingIds.has(task.id)}
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<svg
|
||||
|
|
@ -136,7 +176,7 @@
|
|||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onboardingStore } from '$lib/stores/onboarding.svelte';
|
||||
import { ManaCoreEvents } from '@manacore/shared-utils/analytics';
|
||||
import { profileService } from '$lib/api/profile';
|
||||
import WelcomeStep from './steps/WelcomeStep.svelte';
|
||||
import ProfileStep from './steps/ProfileStep.svelte';
|
||||
import AppsStep from './steps/AppsStep.svelte';
|
||||
|
|
@ -13,6 +14,9 @@
|
|||
|
||||
let { onComplete }: Props = $props();
|
||||
|
||||
// Reference to profile name for auto-save on step transition
|
||||
let profileNameRef = $state('');
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'welcome', label: 'Willkommen', component: WelcomeStep },
|
||||
{ id: 'profile', label: 'Profil', component: ProfileStep },
|
||||
|
|
@ -27,7 +31,16 @@
|
|||
let isLastStep = $derived(currentStep === STEPS.length - 1);
|
||||
let progress = $derived(((currentStep + 1) / STEPS.length) * 100);
|
||||
|
||||
function handleNext() {
|
||||
async function handleNext() {
|
||||
// Auto-save profile name when leaving the profile step
|
||||
if (currentStepData.id === 'profile' && profileNameRef.trim()) {
|
||||
try {
|
||||
await profileService.updateProfile({ name: profileNameRef.trim() });
|
||||
} catch {
|
||||
// Non-blocking: profile save failure shouldn't block onboarding
|
||||
}
|
||||
}
|
||||
|
||||
if (isLastStep) {
|
||||
onboardingStore.complete();
|
||||
onComplete();
|
||||
|
|
@ -139,7 +152,7 @@
|
|||
{#if currentStepData.id === 'welcome'}
|
||||
<WelcomeStep />
|
||||
{:else if currentStepData.id === 'profile'}
|
||||
<ProfileStep />
|
||||
<ProfileStep bind:nameValue={profileNameRef} />
|
||||
{:else if currentStepData.id === 'apps'}
|
||||
<AppsStep />
|
||||
{:else if currentStepData.id === 'credits'}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { profileService } from '$lib/api/profile';
|
||||
|
||||
let { nameValue = $bindable('') }: { nameValue?: string } = $props();
|
||||
|
||||
let name = $state(authStore.user?.name || '');
|
||||
|
||||
// Sync name to parent via bindable
|
||||
$effect(() => {
|
||||
nameValue = name;
|
||||
});
|
||||
let avatarPreview = $state<string | null>(authStore.user?.image || null);
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let saving = $state(false);
|
||||
|
|
|
|||
|
|
@ -82,10 +82,8 @@ export const authStore = {
|
|||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
apps/manacore/apps/web/src/lib/utils/autoRefresh.ts
Normal file
33
apps/manacore/apps/web/src/lib/utils/autoRefresh.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { onMount } from 'svelte';
|
||||
|
||||
/**
|
||||
* Sets up auto-refresh for a widget load function.
|
||||
* Calls load() on mount and then every `intervalMs` milliseconds.
|
||||
* Pauses when the tab is not visible to save resources.
|
||||
*
|
||||
* @param load - The data loading function
|
||||
* @param intervalMs - Refresh interval in ms (default: 60000 = 1 min)
|
||||
*/
|
||||
export function useAutoRefresh(load: () => void | Promise<void>, intervalMs = 60000) {
|
||||
onMount(() => {
|
||||
load();
|
||||
|
||||
let interval = setInterval(load, intervalMs);
|
||||
|
||||
function handleVisibility() {
|
||||
if (document.hidden) {
|
||||
clearInterval(interval);
|
||||
} else {
|
||||
load();
|
||||
interval = setInterval(load, intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@
|
|||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
let isDark = $derived(theme.isDark);
|
||||
let hasCheckedAuth = $state(false);
|
||||
|
||||
// Check auth status on mount
|
||||
|
|
@ -24,4 +26,32 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<!-- Theme toggle for auth pages -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => theme.toggleMode()}
|
||||
class="fixed top-4 right-4 z-50 rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{#if isDark}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{@render children()}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue