managarten/packages/shared-ui/src/settings/SettingsPage.svelte
Till-JS ee42b6cc76 feat: major update with network graphs, themes, todo extensions, and more
## New Features

### Network Graph Visualization (Contacts, Calendar, Todo)
- D3.js force simulation for physics-based layout
- Zoom & pan with mouse/touchpad
- Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus
- Filtering by tags, company/location/project, connection strength
- Shared components in @manacore/shared-ui

### Central Tags API (mana-core-auth)
- CRUD endpoints for tags
- Schema: tags table with userId, name, color, app
- Shared tag components in @manacore/shared-ui

### Custom Themes System
- Theme editor with live preview and color picker
- Community theme gallery
- Theme sharing (public, unlisted, private)
- Backend API in mana-core-auth

### Todo App Extensions
- Glass-pill design for task input and items
- Settings page with 20+ preferences
- Task edit modal with inline editing
- Statistics page with visualizations
- PWA support with offline capabilities
- Multiple kanban boards

### Contacts App Features
- Duplicate detection
- Photo upload
- Batch operations
- Enhanced favorites page with multiple view modes
- Alphabet view improvements
- Search modal

### Help System
- @manacore/shared-help-content
- @manacore/shared-help-ui
- @manacore/shared-help-types

### Other Features
- Themes page for all apps
- Referral system frontend
- CommandBar (global search)
- Skeleton loaders
- Settings page improvements

## Bug Fixes
- Network graph simulation initialization
- Database schema TEXT for user_id columns (Better Auth compatibility)
- Various styling fixes

## Documentation
- Daily report for 2025-12-10
- CI/CD deployment guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 02:37:46 +01:00

262 lines
5.9 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
interface TocItem {
id: string;
title: string;
icon?: string;
}
interface Props {
/** Page title */
title: string;
/** Optional subtitle/description */
subtitle?: string;
/** Maximum width of the content */
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
/** Additional CSS classes */
class?: string;
/** Main content */
children: Snippet;
}
let { title, subtitle, maxWidth = 'md', class: className = '', children }: Props = $props();
const maxWidthClasses = {
sm: 'max-w-lg',
md: 'max-w-2xl',
lg: 'max-w-3xl',
xl: 'max-w-4xl',
};
let tocItems = $state<TocItem[]>([]);
let activeSection = $state<string>('');
let contentEl: HTMLElement;
onMount(() => {
// Collect all section headers
const sections = contentEl.querySelectorAll('[data-settings-section]');
const items: TocItem[] = [];
sections.forEach((section, index) => {
const id = section.getAttribute('data-settings-section') || `section-${index}`;
const titleEl = section.querySelector('.section-title');
const title = titleEl?.textContent || `Section ${index + 1}`;
items.push({ id, title });
});
tocItems = items;
// Find the currently active section based on scroll position
function updateActiveSection() {
const scrollPosition = window.scrollY + window.innerHeight;
const pageHeight = document.documentElement.scrollHeight;
const bottomThreshold = 50;
// If at bottom of page, activate last section
if (pageHeight - scrollPosition <= bottomThreshold && tocItems.length > 0) {
activeSection = tocItems[tocItems.length - 1].id;
return;
}
// Find which section is currently in view
const viewportTop = window.scrollY + 120; // Account for sticky header offset
let currentSection = '';
sections.forEach((section) => {
const rect = section.getBoundingClientRect();
const sectionTop = rect.top + window.scrollY;
// Section is active if its top is above our viewport check point
if (sectionTop <= viewportTop) {
const id = section.getAttribute('data-settings-section');
if (id) currentSection = id;
}
});
if (currentSection) {
activeSection = currentSection;
} else if (tocItems.length > 0) {
// Default to first section if nothing else matches
activeSection = tocItems[0].id;
}
}
// Initial check
updateActiveSection();
// Update on scroll
window.addEventListener('scroll', updateActiveSection, { passive: true });
return () => {
window.removeEventListener('scroll', updateActiveSection);
};
});
function scrollToSection(id: string) {
const section = contentEl.querySelector(`[data-settings-section="${id}"]`);
if (section) {
const y = section.getBoundingClientRect().top + window.scrollY - 100;
window.scrollTo({ top: y, behavior: 'smooth' });
}
}
</script>
<div class="settings-page bg-background {className}">
<!-- Table of Contents - Desktop only -->
<aside class="toc-sidebar">
<div class="toc-container">
<p class="toc-title">Inhalt</p>
<nav class="toc-nav">
{#each tocItems as item}
<button
class="toc-item"
class:active={activeSection === item.id}
onclick={() => scrollToSection(item.id)}
>
{item.title}
</button>
{/each}
</nav>
</div>
</aside>
<!-- Main Content -->
<main class="settings-main">
<div class="settings-content {maxWidthClasses[maxWidth]}">
<header class="settings-header">
<h1 class="text-2xl sm:text-[1.75rem] font-bold text-foreground m-0">{title}</h1>
{#if subtitle}
<p class="text-sm text-muted-foreground mt-1">{subtitle}</p>
{/if}
</header>
<div class="sections-container" bind:this={contentEl}>
{@render children()}
</div>
</div>
</main>
</div>
<style>
.settings-page {
min-height: calc(100vh - 4rem);
position: relative;
}
/* Table of Contents Sidebar - Fixed position on the left */
.toc-sidebar {
display: none;
}
@media (min-width: 1400px) {
.toc-sidebar {
display: block;
position: fixed;
left: 2rem;
top: 100px;
width: 240px;
max-height: calc(100vh - 140px);
overflow-y: auto;
z-index: 10;
}
}
.toc-container {
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);
border-radius: 1rem;
padding: 1.25rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .toc-container {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.toc-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .toc-title {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.toc-nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.toc-item {
display: block;
width: 100%;
text-align: left;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toc-item:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 0.5);
}
.toc-item.active {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
font-weight: 600;
}
/* Main Content Area - Always centered */
.settings-main {
width: 100%;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.settings-main {
padding: 2rem 1.5rem;
}
}
@media (min-width: 1024px) {
.settings-main {
padding: 2rem;
}
}
.settings-content {
margin-left: auto;
margin-right: auto;
}
.settings-header {
margin-bottom: 2rem;
}
.sections-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
</style>