mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 03:29:40 +02:00
## 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>
262 lines
5.9 KiB
Svelte
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>
|