feat(todo/web, shared-i18n): complete i18n for Todo web app + add missing common translations

Extract ~120 hardcoded German strings from 14 Svelte components into i18n locale
files using svelte-i18n $t() calls. Add new translation sections (taskForm, filters,
tags, subtasks, durationPicker, kanban, toolbar) across all 5 languages (de/en/fr/es/it).

Also add missing shared common translations for Spanish, French, and Italian
(150+ keys each) in packages/shared-i18n.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 14:19:48 +02:00
parent 5c66492279
commit cb85fba820
30 changed files with 2145 additions and 248 deletions

View file

@ -4,6 +4,8 @@
import { flip } from 'svelte/animate';
import { untrack } from 'svelte';
import { Check, Plus, X, DotsSixVertical } from '@manacore/shared-icons';
import { TodoEvents } from '@manacore/shared-utils/analytics';
import { t } from 'svelte-i18n';
interface Props {
subtasks: Subtask[];
@ -20,7 +22,9 @@
const current = subtasks;
untrack(() => {
const currentIds = new Set(current.map((s) => s.id));
const itemIds = new Set(items.filter((i) => i.id !== SHADOW_PLACEHOLDER_ITEM_ID).map((i) => i.id));
const itemIds = new Set(
items.filter((i) => i.id !== SHADOW_PLACEHOLDER_ITEM_ID).map((i) => i.id)
);
const idsChanged =
currentIds.size !== itemIds.size || current.some((s) => !itemIds.has(s.id));
@ -42,10 +46,13 @@
items = e.detail.items.filter((item) => item.id !== SHADOW_PLACEHOLDER_ITEM_ID);
onChange(items.map((item, index) => ({ ...item, order: index })));
dropInProgress = true;
setTimeout(() => { dropInProgress = false; }, 500);
setTimeout(() => {
dropInProgress = false;
}, 500);
}
function toggleComplete(id: string) {
const target = subtasks.find((s) => s.id === id);
const updated = subtasks.map((s) =>
s.id === id
? {
@ -55,6 +62,7 @@
}
: s
);
if (target && !target.isCompleted) TodoEvents.subtaskCompleted();
onChange(updated);
}
@ -122,7 +130,7 @@
}}
>
<!-- Drag handle -->
<div class="drag-handle" aria-label="Ziehen zum Sortieren">
<div class="drag-handle" aria-label={$t('subtasks.dragToSort')}>
<DotsSixVertical size={16} />
</div>
@ -147,15 +155,15 @@
spellcheck="false"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => handleTitleKeydown(e, subtask)}
onblur={(e) => handleTitleBlur(e, subtask)}
>{subtask.title}</span>
onblur={(e) => handleTitleBlur(e, subtask)}>{subtask.title}</span
>
<!-- Delete button -->
<button
type="button"
class="subtask-delete"
onclick={() => deleteSubtask(subtask.id)}
title="Löschen"
title={$t('common.delete')}
>
<X size={16} />
</button>
@ -172,12 +180,12 @@
<input
type="text"
class="add-input"
placeholder="Subtask hinzufügen..."
placeholder={$t('subtasks.addPlaceholder')}
bind:value={newSubtaskTitle}
onkeydown={handleAddKeydown}
/>
{#if newSubtaskTitle.trim()}
<button type="button" class="add-btn" onclick={addSubtask}> Hinzufügen </button>
<button type="button" class="add-btn" onclick={addSubtask}> {$t('subtasks.add')} </button>
{/if}
</div>
</div>

View file

@ -5,6 +5,7 @@
import { goto } from '$app/navigation';
import { DotsThree, Plus, X } from '@manacore/shared-icons';
import TagStripModal from './TagStripModal.svelte';
import { t } from 'svelte-i18n';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
@ -54,7 +55,7 @@
class="clear-filter-pill glass-tag"
class:hidden={!hasSelectedTags}
onclick={() => viewStore.setFilterLabelIds([])}
title="Filter löschen"
title={$t('filters.clearFilter')}
disabled={!hasSelectedTags}
>
<X size={16} weight="bold" />
@ -62,15 +63,15 @@
</button>
<!-- More Pill (opens modal) -->
<button class="more-pill glass-tag" onclick={handleOpenModal} title="Alle Tags anzeigen">
<button class="more-pill glass-tag" onclick={handleOpenModal} title={$t('tags.showAllTags')}>
<DotsThree size={18} weight="bold" />
<span class="tag-name">Alle Tags</span>
<span class="tag-name">{$t('tags.allTags')}</span>
</button>
{#if !hasTags}
<button class="empty-state glass-tag" onclick={() => goto('/tags')}>
<span>Keine Tags vorhanden</span>
<span class="add-hint">+ Erstellen</span>
<span>{$t('tags.noTagsAvailable')}</span>
<span class="add-hint">{$t('tags.createShort')}</span>
</button>
{:else}
{#each sortedTags as tag (tag.id)}
@ -90,10 +91,10 @@
<button
class="create-pill glass-tag"
onclick={() => goto('/tags?new=true')}
title="Neuer Tag"
title={$t('tags.newTag')}
>
<Plus size={16} weight="bold" />
<span class="tag-name">Neuer Tag</span>
<span class="tag-name">{$t('tags.newTag')}</span>
</button>
{/if}
</div>

View file

@ -4,6 +4,7 @@
import { tagMutations } from '@manacore/shared-stores';
import { Plus, X, Check, Pencil, Trash, MagnifyingGlass } from '@manacore/shared-icons';
import { TagColorPicker, focusTrap } from '@manacore/shared-ui';
import { t } from 'svelte-i18n';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
@ -156,10 +157,10 @@
<div class="modal-header">
<h2 class="modal-title">Tags</h2>
<div class="header-actions">
<button class="header-btn" onclick={openNewTagForm} title="Neuer Tag">
<button class="header-btn" onclick={openNewTagForm} title={$t('tags.newTag')}>
<Plus size={18} weight="bold" />
</button>
<button class="header-btn close-btn" onclick={onClose} title="Schließen">
<button class="header-btn close-btn" onclick={onClose} title={$t('common.close')}>
<X size={18} weight="bold" />
</button>
</div>
@ -169,10 +170,10 @@
<div class="modal-content">
{#if tagsCtx.value.length === 0 && !showNewTagForm}
<div class="empty-state">
<p>Keine Tags vorhanden</p>
<p>{$t('tags.noTagsAvailable')}</p>
<button class="create-btn" onclick={openNewTagForm}>
<Plus size={16} weight="bold" />
Tag erstellen
{$t('tags.createTag')}
</button>
</div>
{:else}
@ -180,8 +181,8 @@
{#if showNewTagForm}
<div class="edit-form-section">
<div class="edit-form-header">
<span class="edit-form-title">Neuer Tag</span>
<button class="icon-btn" onclick={closeNewTagForm} title="Abbrechen">
<span class="edit-form-title">{$t('tags.newTag')}</span>
<button class="icon-btn" onclick={closeNewTagForm} title={$t('common.cancel')}>
<X size={14} weight="bold" />
</button>
</div>
@ -192,7 +193,7 @@
type="text"
bind:value={newTagName}
onkeydown={handleNewTagKeydown}
placeholder="Tag Name"
placeholder={$t('tags.tagName')}
class="name-input"
autofocus
/>
@ -210,7 +211,7 @@
disabled={!newTagName.trim() || isCreatingTag}
>
<Check size={14} weight="bold" />
Erstellen
{$t('tags.createTag')}
</button>
</div>
</div>
@ -221,8 +222,8 @@
{#if editingTag}
<div class="edit-form-section">
<div class="edit-form-header">
<span class="edit-form-title">Tag bearbeiten</span>
<button class="icon-btn" onclick={closeEditTag} title="Abbrechen">
<span class="edit-form-title">{$t('tags.editTag')}</span>
<button class="icon-btn" onclick={closeEditTag} title={$t('common.cancel')}>
<X size={14} weight="bold" />
</button>
</div>
@ -233,7 +234,7 @@
type="text"
bind:value={editTagName}
onkeydown={handleEditTagKeydown}
placeholder="Tag Name"
placeholder={$t('tags.tagName')}
class="name-input"
autofocus
/>
@ -245,7 +246,11 @@
/>
</div>
<div class="form-actions">
<button class="btn btn-danger" onclick={handleDeleteTag} title="Tag löschen">
<button
class="btn btn-danger"
onclick={handleDeleteTag}
title={$t('tags.deleteTag')}
>
<Trash size={14} weight="bold" />
</button>
<button
@ -254,7 +259,7 @@
disabled={!editTagName.trim() || isSavingTag}
>
<Check size={14} weight="bold" />
Speichern
{$t('common.save')}
</button>
</div>
</div>
@ -276,7 +281,7 @@
<button
class="tag-edit-btn"
onclick={() => openEditTag(tag)}
title="Tag bearbeiten"
title={$t('tags.editTag')}
>
<Pencil size={10} weight="bold" />
</button>
@ -286,7 +291,7 @@
{#if searchQuery && sortedTags.length === 0}
<div class="search-empty">
<p>Keine Tags gefunden für "{searchQuery}"</p>
<p>{$t('tags.noTagsFound', { values: { query: searchQuery } })}</p>
</div>
{/if}
{/if}
@ -298,12 +303,16 @@
<MagnifyingGlass size={16} class="search-icon" />
<input
type="text"
placeholder="Tags suchen..."
placeholder={$t('tags.searchTags')}
bind:value={searchQuery}
class="search-input"
/>
{#if searchQuery}
<button class="search-clear" onclick={() => (searchQuery = '')} title="Suche leeren">
<button
class="search-clear"
onclick={() => (searchQuery = '')}
title={$t('tags.clearSearch')}
>
<X size={14} weight="bold" />
</button>
{/if}

View file

@ -10,11 +10,13 @@
DurationPicker,
FunRatingPicker,
TagSelector,
ReminderSelector,
} from './form';
import { ContactSelector, focusTrap } from '@manacore/shared-ui';
import { ManaLinkList, ManaLinkPicker } from '@manacore/shared-links/ui';
import { searchCrossApp } from '$lib/data/cross-app-search';
import { X, Trash } from '@manacore/shared-icons';
import { t } from 'svelte-i18n';
interface Props {
task: Task;
@ -50,6 +52,7 @@
form.isLoading = true;
try {
onSave(form.buildUpdateInput(task));
await form.persistReminder(task.id);
} finally {
form.isLoading = false;
}
@ -84,13 +87,15 @@
<div class="top-bar">
<div class="top-left">
{#if form.showDeleteConfirm}
<span class="delete-confirm-text">Wirklich löschen?</span>
<button class="btn-ghost-danger" onclick={handleDelete}>Ja, löschen</button>
<span class="delete-confirm-text">{$t('taskForm.confirmDelete')}</span>
<button class="btn-ghost-danger" onclick={handleDelete}
>{$t('taskForm.yesDelete')}</button
>
<button class="btn-ghost" onclick={() => (form.showDeleteConfirm = false)}
>Abbrechen</button
>{$t('common.cancel')}</button
>
{:else}
<button class="btn-icon-danger" onclick={handleDelete} title="Aufgabe löschen">
<button class="btn-icon-danger" onclick={handleDelete} title={$t('task.deleteTask')}>
<Trash size={16} />
</button>
{/if}
@ -101,9 +106,11 @@
onclick={handleSave}
disabled={form.isLoading || !form.title.trim()}
>
{#if form.isLoading}<span class="spinner"></span>{:else}Speichern{/if}
{#if form.isLoading}<span class="spinner"></span>{:else}{$t('common.save')}{/if}
</button>
<button class="btn-close" onclick={onClose} title="Schließen"><X size={18} /></button>
<button class="btn-close" onclick={onClose} title={$t('common.close')}
><X size={18} /></button
>
</div>
</div>
@ -112,7 +119,7 @@
<textarea
class="title-input"
bind:value={form.title}
placeholder="Aufgabentitel..."
placeholder={$t('taskForm.titlePlaceholder')}
rows="1"
use:autoGrow
></textarea>
@ -121,23 +128,25 @@
<!-- Content: Description (left) + Subtasks/Links (right) -->
<div class="content-grid">
<div class="col-desc">
<span class="col-label">Beschreibung</span>
<span class="col-label">{$t('taskForm.description')}</span>
<textarea
class="desc-textarea"
bind:value={form.description}
placeholder="Beschreibung hinzufügen..."
placeholder={$t('taskForm.addDescription')}
rows="5"
></textarea>
</div>
<div class="col-subtasks">
<span class="col-label">Subtasks</span>
<span class="col-label">{$t('taskForm.subtasks')}</span>
<SubtaskList subtasks={form.subtasks} onChange={handleSubtasksChange} />
<div class="links-block">
<div class="links-header">
<span class="col-label">Verknüpfungen</span>
<button class="link-add" onclick={() => (showLinkPicker = true)}>+ Verknüpfen</button>
<span class="col-label">{$t('taskForm.links')}</span>
<button class="link-add" onclick={() => (showLinkPicker = true)}
>{$t('taskForm.addLink')}</button
>
</div>
<ManaLinkList recordRef={{ app: 'todo', collection: 'tasks', id: task.id }} editable />
</div>
@ -148,7 +157,7 @@
<div class="props-strip">
<!-- Status -->
<div class="prop">
<span class="prop-label">Status</span>
<span class="prop-label">{$t('taskForm.status')}</span>
<select class="prop-select" bind:value={form.status}>
{#each STATUS_OPTIONS as s}
<option value={s.value}>{s.label}</option>
@ -160,7 +169,7 @@
<!-- Priorität -->
<div class="prop prop-priority">
<span class="prop-label">Priorität</span>
<span class="prop-label">{$t('task.priority')}</span>
<PrioritySelector value={form.priority} onChange={(p) => (form.priority = p)} />
</div>
@ -168,25 +177,25 @@
<!-- Fälligkeit -->
<div class="prop">
<span class="prop-label">Fälligkeit</span>
<span class="prop-label">{$t('taskForm.dueDate')}</span>
<input type="date" class="prop-input" bind:value={form.dueDate} />
</div>
<!-- Uhrzeit -->
<div class="prop">
<span class="prop-label">Uhrzeit</span>
<span class="prop-label">{$t('taskForm.time')}</span>
<input type="time" class="prop-input" bind:value={form.dueTime} />
</div>
<!-- Startdatum -->
<div class="prop">
<span class="prop-label">Startdatum</span>
<span class="prop-label">{$t('taskForm.startDate')}</span>
<input type="date" class="prop-input" bind:value={form.startDate} />
</div>
<!-- Wiederholung -->
<div class="prop">
<span class="prop-label">Wiederholung</span>
<span class="prop-label">{$t('taskForm.recurrence')}</span>
<select class="prop-select" bind:value={form.recurrenceRule}>
{#each RECURRENCE_OPTIONS as o}
<option value={o.value}>{o.label}</option>
@ -194,11 +203,21 @@
</select>
</div>
<!-- Erinnerung -->
<div class="prop">
<span class="prop-label">{$t('reminders.label')}</span>
<ReminderSelector
value={form.reminderMinutes}
onChange={(v) => (form.reminderMinutes = v)}
disabled={!form.dueDate}
/>
</div>
<div class="prop-divider"></div>
<!-- Tags -->
<div class="prop prop-tags">
<span class="prop-label">Tags</span>
<span class="prop-label">{$t('taskForm.tags')}</span>
<TagSelector
selectedIds={form.selectedLabelIds}
onChange={(ids) => (form.selectedLabelIds = ids)}
@ -209,31 +228,31 @@
<!-- Zuständig -->
<div class="prop prop-contact">
<span class="prop-label">Zuständig</span>
<span class="prop-label">{$t('taskForm.assignee')}</span>
<ContactSelector
selectedContacts={form.assignee}
onContactsChange={(c) => (form.assignee = c)}
onSearch={(q) => contactsStore.searchContacts(q)}
singleSelect={true}
allowManualEntry={false}
placeholder="Zuweisen..."
addLabel="Zuweisen"
searchPlaceholder="Name oder E-Mail..."
placeholder={$t('taskForm.assignPlaceholder')}
addLabel={$t('taskForm.assignLabel')}
searchPlaceholder={$t('taskForm.nameOrEmail')}
isAvailable={form.contactsAvailable ?? false}
/>
</div>
<!-- Beteiligte -->
<div class="prop prop-contact">
<span class="prop-label">Beteiligte</span>
<span class="prop-label">{$t('taskForm.involved')}</span>
<ContactSelector
selectedContacts={form.involvedContacts}
onContactsChange={(c) => (form.involvedContacts = c)}
onSearch={(q) => contactsStore.searchContacts(q)}
allowManualEntry={false}
placeholder="Hinzufügen..."
addLabel="Hinzufügen"
searchPlaceholder="Name oder E-Mail..."
placeholder={$t('taskForm.addPlaceholder')}
addLabel={$t('taskForm.addLabel')}
searchPlaceholder={$t('taskForm.nameOrEmail')}
isAvailable={form.contactsAvailable ?? false}
/>
</div>
@ -242,13 +261,13 @@
<!-- Storypoints -->
<div class="prop">
<span class="prop-label">Storypoints</span>
<span class="prop-label">{$t('taskForm.storypoints')}</span>
<StorypointsSelector value={form.storyPoints} onChange={(v) => (form.storyPoints = v)} />
</div>
<!-- Effektive Dauer -->
<div class="prop">
<span class="prop-label">Dauer</span>
<span class="prop-label">{$t('taskForm.duration')}</span>
<DurationPicker
value={form.effectiveDuration}
onChange={(v) => (form.effectiveDuration = v)}
@ -257,7 +276,8 @@
<!-- Spaß-Faktor -->
<div class="prop">
<span class="prop-label">Spaß{form.funRating !== null ? ` (${form.funRating})` : ''}</span
<span class="prop-label"
>{$t('taskForm.fun')}{form.funRating !== null ? ` (${form.funRating})` : ''}</span
>
<FunRatingPicker value={form.funRating} onChange={(v) => (form.funRating = v)} />
</div>

View file

@ -7,6 +7,7 @@
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
import type { SortBy, SortOrder } from '$lib/stores/view.svelte';
import { CaretDown, Check, CheckCircle, MagnifyingGlass, X } from '@manacore/shared-icons';
import { t } from 'svelte-i18n';
interface Props {
// Layout
@ -61,18 +62,38 @@
onToggleCompleted,
}: Props = $props();
const priorities: { value: TaskPriority; label: string; color: string; bgColor: string }[] = [
{ value: 'urgent', label: 'Dringend', color: '#ef4444', bgColor: 'bg-red-500' },
{ value: 'high', label: 'Hoch', color: '#f97316', bgColor: 'bg-orange-500' },
{ value: 'medium', label: 'Normal', color: '#eab308', bgColor: 'bg-yellow-500' },
{ value: 'low', label: 'Niedrig', color: '#3b82f6', bgColor: 'bg-blue-500' },
];
let priorities = $derived([
{
value: 'urgent' as TaskPriority,
label: $t('priority.urgent'),
color: '#ef4444',
bgColor: 'bg-red-500',
},
{
value: 'high' as TaskPriority,
label: $t('priority.high'),
color: '#f97316',
bgColor: 'bg-orange-500',
},
{
value: 'medium' as TaskPriority,
label: $t('priority.medium'),
color: '#eab308',
bgColor: 'bg-yellow-500',
},
{
value: 'low' as TaskPriority,
label: $t('priority.low'),
color: '#3b82f6',
bgColor: 'bg-blue-500',
},
]);
const sortOptions: { id: SortBy; label: string }[] = [
{ id: 'dueDate', label: 'Datum' },
{ id: 'priority', label: 'Priorit.' },
{ id: 'title', label: 'Name' },
];
let sortOptions = $derived([
{ id: 'dueDate' as SortBy, label: $t('filters.date') },
{ id: 'priority' as SortBy, label: $t('filters.priorityShort') },
{ id: 'title' as SortBy, label: $t('filters.name') },
]);
// Dropdown states
let showLabelsDropdown = $state(false);
@ -107,7 +128,7 @@
class="clear-filter-pill glass-pill"
class:hidden={!hasActiveFilters}
onclick={onClearFilters}
title="Filter löschen"
title={$t('filters.clearFilter')}
disabled={!hasActiveFilters}
>
<X size={16} weight="bold" />
@ -116,7 +137,11 @@
<!-- Tag Chips -->
{#if showTags}
<button class="label-pill glass-pill" onclick={() => goto('/tags')} title="Tags verwalten">
<button
class="label-pill glass-pill"
onclick={() => goto('/tags')}
title={$t('filters.manageTags')}
>
<span class="pill-label label-text">Tags:</span>
</button>
{#if tagsCtx.value.length > 0}
@ -163,7 +188,7 @@
class="sort-pill glass-pill"
class:active={sortBy === option.id}
onclick={() => onSortChange(option.id)}
title="Nach {option.label} sortieren"
title={$t('tags.sortBy', { values: { field: option.label } })}
>
<span class="pill-label">{option.label}</span>
</button>
@ -176,10 +201,10 @@
class="glass-pill"
class:active={isCompletedVisible}
onclick={onToggleCompleted}
title={isCompletedVisible ? 'Erledigte ausblenden' : 'Erledigte anzeigen'}
title={isCompletedVisible ? $t('filters.hideCompleted') : $t('filters.showCompleted')}
>
<CheckCircle size={20} class="pill-icon" />
<span class="pill-label">Erledigt</span>
<span class="pill-label">{$t('nav.completed')}</span>
</button>
{/if}
</div>
@ -200,7 +225,7 @@
type="text"
value={searchQuery}
oninput={(e) => onSearchChange(e.currentTarget.value)}
placeholder="Aufgaben suchen..."
placeholder={$t('filters.searchTasks')}
class="w-full pl-10 pr-8 py-2 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary placeholder:text-muted-foreground transition-all"
/>
{#if searchQuery}
@ -219,7 +244,7 @@
onclick={onClearFilters}
>
<X size={16} />
Zurücksetzen
{$t('filters.resetFilters')}
</button>
{/if}
</div>
@ -230,7 +255,7 @@
<!-- Priority filters -->
<div class="filter-group flex items-center gap-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide"
>Priorität</span
>{$t('task.priority')}</span
>
<div class="flex items-center gap-1">
{#each priorities as priority}
@ -277,7 +302,7 @@
{/if}
</div>
{:else}
<span class="text-muted-foreground">Auswählen</span>
<span class="text-muted-foreground">{$t('filters.select')}</span>
{/if}
<CaretDown
size={16}
@ -294,7 +319,9 @@
class="absolute top-full left-0 mt-2 z-50 min-w-[220px] bg-popover border border-border rounded-xl shadow-lg p-2 animate-in fade-in slide-in-from-top-2 duration-150"
>
{#if tagsCtx.value.length === 0}
<p class="text-sm text-muted-foreground p-3 text-center">Keine Tags vorhanden</p>
<p class="text-sm text-muted-foreground p-3 text-center">
{$t('filters.noTagsAvailable')}
</p>
{:else}
<div class="max-h-[200px] overflow-y-auto">
{#each tagsCtx.value as label}

View file

@ -17,8 +17,11 @@
DurationPicker,
FunRatingPicker,
TagSelector,
ReminderSelector,
} from './form';
import { PRIORITY_COLORS } from '$lib/constants/priority';
import { TodoEvents } from '@manacore/shared-utils/analytics';
import { t } from 'svelte-i18n';
interface Props {
task: Task;
@ -124,6 +127,7 @@
form.funRating,
form.assignee,
form.involvedContacts,
form.reminderMinutes,
];
scheduleAutoSave();
});
@ -208,6 +212,7 @@
try {
const data = form.buildUpdateInput(task);
onSave(data);
await form.persistReminder(task.id);
} finally {
form.isLoading = false;
}
@ -228,6 +233,7 @@
function toggleSubtask(subtaskId: string) {
if (!onSave) return;
const subtasks = $state.snapshot(task.subtasks) ?? [];
const target = subtasks.find((s) => s.id === subtaskId);
const updated = subtasks.map((s) =>
s.id === subtaskId
? {
@ -237,6 +243,7 @@
}
: s
);
if (target && !target.isCompleted) TodoEvents.subtaskCompleted();
onSave({ subtasks: updated });
}
@ -343,11 +350,7 @@
/>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="task-title"
class:line-through={task.isCompleted}
onclick={startTitleEdit}
>
<span class="task-title" class:line-through={task.isCompleted} onclick={startTitleEdit}>
{task.title}
</span>
{/if}
@ -371,7 +374,12 @@
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
<div class="contacts-display">
{#if task.metadata?.assignee}
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
<div
class="assignee-avatar"
title={$t('kanban.assignedTo', {
values: { name: task.metadata.assignee.displayName },
})}
>
<ContactAvatar
name={task.metadata.assignee.displayName}
photoUrl={task.metadata.assignee.photoUrl}
@ -382,7 +390,10 @@
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
<div class="involved-avatars">
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
<div
class="involved-avatar"
title={$t('kanban.involvedContact', { values: { name: contact.displayName } })}
>
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
</div>
{/each}
@ -403,15 +414,15 @@
e.stopPropagation();
showCreatedDate = !showCreatedDate;
}}
title="Klicken für Erstellungsdatum"
title={$t('taskForm.clickForCreatedDate')}
>
{#if showCreatedDate}
<span class="date-label">Erstellt</span>
<span class="date-label">{$t('taskForm.created')}</span>
<span class="date-value"
>{format(new Date(task.createdAt), 'd. MMM yyyy', { locale: de })}</span
>
{/if}
<span class="date-label">Erledigt</span>
<span class="date-label">{$t('taskForm.completed')}</span>
<span class="date-value"
>{format(new Date(task.completedAt), 'd. MMM yyyy', { locale: de })}</span
>
@ -431,7 +442,7 @@
type="button"
class="detail-btn"
onclick={handleOpenModal}
title="Details öffnen"
title={$t('taskForm.openDetails')}
tabindex="-1"
>
<ArrowsOutSimple size={14} />
@ -470,35 +481,37 @@
<div class="expanded-form">
<!-- Title -->
<div class="form-section">
<label class="form-label" for="task-title-{task.id}">Titel</label>
<label class="form-label" for="task-title-{task.id}">{$t('task.title')}</label>
<input
bind:this={titleInputRef}
id="task-title-{task.id}"
type="text"
class="form-input"
bind:value={form.title}
placeholder="Aufgabentitel..."
placeholder={$t('taskForm.titlePlaceholder')}
/>
</div>
<!-- Description -->
<div class="form-section">
<label class="form-label" for="task-description-{task.id}">Beschreibung</label>
<label class="form-label" for="task-description-{task.id}"
>{$t('taskForm.description')}</label
>
<textarea
id="task-description-{task.id}"
class="form-textarea"
bind:value={form.description}
placeholder="Beschreibung hinzufügen..."
placeholder={$t('taskForm.addDescription')}
rows="2"
></textarea>
</div>
<!-- Time planning row -->
<div class="form-section">
<label class="form-label">Zeitplanung</label>
<label class="form-label">{$t('taskForm.scheduling')}</label>
<div class="form-row">
<div class="form-field">
<label class="form-sublabel" for="due-date-{task.id}">Fällig</label>
<label class="form-sublabel" for="due-date-{task.id}">{$t('taskForm.due')}</label>
<input
id="due-date-{task.id}"
type="date"
@ -507,7 +520,7 @@
/>
</div>
<div class="form-field">
<label class="form-sublabel" for="due-time-{task.id}">Uhrzeit</label>
<label class="form-sublabel" for="due-time-{task.id}">{$t('taskForm.time')}</label>
<input
id="due-time-{task.id}"
type="time"
@ -516,7 +529,7 @@
/>
</div>
<div class="form-field">
<label class="form-sublabel" for="start-date-{task.id}">Start</label>
<label class="form-sublabel" for="start-date-{task.id}">{$t('taskForm.start')}</label>
<input
id="start-date-{task.id}"
type="date"
@ -529,13 +542,13 @@
<!-- Priority -->
<div class="form-section">
<label class="form-label">Priorität</label>
<label class="form-label">{$t('task.priority')}</label>
<PrioritySelector value={form.priority} onChange={(p) => (form.priority = p)} />
</div>
<!-- Status -->
<div class="form-section">
<label class="form-label" for="task-status-{task.id}">Status</label>
<label class="form-label" for="task-status-{task.id}">{$t('taskForm.status')}</label>
<select id="task-status-{task.id}" class="form-select" bind:value={form.status}>
{#each STATUS_OPTIONS as s}
<option value={s.value}>{s.label}</option>
@ -545,7 +558,7 @@
<!-- Tags -->
<div class="form-section">
<label class="form-label">Tags</label>
<label class="form-label">{$t('taskForm.tags')}</label>
<TagSelector
selectedIds={form.selectedLabelIds}
onChange={(ids) => (form.selectedLabelIds = ids)}
@ -554,13 +567,14 @@
<!-- Subtasks -->
<div class="form-section">
<label class="form-label">Subtasks</label>
<label class="form-label">{$t('taskForm.subtasks')}</label>
<SubtaskList subtasks={form.subtasks} onChange={handleSubtasksChange} />
</div>
<!-- Recurrence -->
<div class="form-section">
<label class="form-label" for="task-recurrence-{task.id}">Wiederholung</label>
<label class="form-label" for="task-recurrence-{task.id}">{$t('taskForm.recurrence')}</label
>
<select id="task-recurrence-{task.id}" class="form-select" bind:value={form.recurrenceRule}>
{#each RECURRENCE_OPTIONS as option}
<option value={option.value}>{option.label}</option>
@ -568,33 +582,43 @@
</select>
</div>
<!-- Reminder -->
<div class="form-section">
<label class="form-label">{$t('reminders.label')}</label>
<ReminderSelector
value={form.reminderMinutes}
onChange={(v) => (form.reminderMinutes = v)}
disabled={!form.dueDate}
/>
</div>
<!-- Contacts: Assignee -->
<div class="form-section">
<label class="form-label">Zuständig</label>
<label class="form-label">{$t('taskForm.assignee')}</label>
<ContactSelector
selectedContacts={form.assignee}
onContactsChange={(contacts) => (form.assignee = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
singleSelect={true}
allowManualEntry={false}
placeholder="Person zuweisen..."
addLabel="Zuweisen"
searchPlaceholder="Name oder E-Mail..."
placeholder={$t('taskForm.assignPerson')}
addLabel={$t('taskForm.assignLabel')}
searchPlaceholder={$t('taskForm.nameOrEmail')}
isAvailable={form.contactsAvailable ?? false}
/>
</div>
<!-- Contacts: Involved -->
<div class="form-section">
<label class="form-label">Beteiligte</label>
<label class="form-label">{$t('taskForm.involved')}</label>
<ContactSelector
selectedContacts={form.involvedContacts}
onContactsChange={(contacts) => (form.involvedContacts = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
allowManualEntry={false}
placeholder="Personen hinzufügen..."
addLabel="Person hinzufügen"
searchPlaceholder="Name oder E-Mail..."
placeholder={$t('taskForm.addPeoplePlaceholder')}
addLabel={$t('taskForm.addPersonLabel')}
searchPlaceholder={$t('taskForm.nameOrEmail')}
isAvailable={form.contactsAvailable ?? false}
/>
</div>
@ -602,18 +626,18 @@
<!-- Story Points & Duration & Fun Rating row -->
<div class="form-row-3">
<div class="form-section">
<label class="form-label">Storypoints</label>
<label class="form-label">{$t('taskForm.storypoints')}</label>
<StorypointsSelector value={form.storyPoints} onChange={(v) => (form.storyPoints = v)} />
</div>
<div class="form-section">
<label class="form-label">Dauer</label>
<label class="form-label">{$t('taskForm.duration')}</label>
<DurationPicker
value={form.effectiveDuration}
onChange={(v) => (form.effectiveDuration = v)}
/>
</div>
<div class="form-section">
<label class="form-label">Spaß</label>
<label class="form-label">{$t('taskForm.fun')}</label>
<FunRatingPicker value={form.funRating} onChange={(v) => (form.funRating = v)} />
</div>
</div>
@ -626,7 +650,7 @@
onclick={handleDeleteClick}
disabled={form.isLoading}
>
{form.showDeleteConfirm ? 'Wirklich löschen?' : 'Löschen'}
{form.showDeleteConfirm ? $t('taskForm.confirmDelete') : $t('common.delete')}
</button>
</div>
</div>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { ExpandableToolbar } from '@manacore/shared-ui';
import TodoToolbarContent from './TodoToolbarContent.svelte';
import { t } from 'svelte-i18n';
interface Props {
isCollapsed?: boolean;
@ -15,8 +16,8 @@
{isCollapsed}
{onCollapsedChange}
{bottomOffset}
collapsedTitle="Aufgaben-Optionen"
expandedTitle="Schließen"
collapsedTitle={$t('toolbar.taskOptions')}
expandedTitle={$t('common.close')}
>
{#snippet collapsedIcon()}
<!-- Task/list icon -->

View file

@ -5,6 +5,7 @@
import { todoSettings } from '$lib/stores/settings.svelte';
import { PillToolbarButton, PillToolbarDivider, PillViewSwitcher } from '@manacore/shared-ui';
import { CheckCircle, Columns, Funnel } from '@manacore/shared-icons';
import { t } from 'svelte-i18n';
interface Props {
/** Vertical layout (for sidebar mode) */
@ -32,19 +33,19 @@
let selectedPriorityFilters = $state<TaskPriority[]>([]);
let selectedLabelFilters = $state<string[]>([]);
const priorities: { value: TaskPriority; label: string; color: string }[] = [
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
{ value: 'high', label: 'Hoch', color: '#f97316' },
{ value: 'medium', label: 'Normal', color: '#eab308' },
{ value: 'low', label: 'Niedrig', color: '#3b82f6' },
];
let priorities = $derived([
{ value: 'urgent' as TaskPriority, label: $t('priority.urgent'), color: '#ef4444' },
{ value: 'high' as TaskPriority, label: $t('priority.high'), color: '#f97316' },
{ value: 'medium' as TaskPriority, label: $t('priority.medium'), color: '#eab308' },
{ value: 'low' as TaskPriority, label: $t('priority.low'), color: '#3b82f6' },
]);
// Sort options
const sortOptions = [
{ id: 'dueDate', label: 'Datum', title: 'Nach Fälligkeitsdatum sortieren' },
{ id: 'priority', label: 'Priorität', title: 'Nach Priorität sortieren' },
{ id: 'title', label: 'Name', title: 'Alphabetisch sortieren' },
];
let sortOptions = $derived([
{ id: 'dueDate', label: $t('filters.date'), title: $t('filters.sortByDueDate') },
{ id: 'priority', label: $t('task.priority'), title: $t('filters.sortByPriority') },
{ id: 'title', label: $t('filters.name'), title: $t('filters.sortAlphabetical') },
]);
// Count active filters
let activeFilterCount = $derived(selectedPriorityFilters.length + selectedLabelFilters.length);
@ -81,7 +82,7 @@
const idx = modes.indexOf(todoSettings.activeLayoutMode);
todoSettings.set('activeLayoutMode', modes[(idx + 1) % modes.length]);
}}
title="Ansicht wechseln"
title={$t('toolbar.switchView')}
>
<Columns size={20} />
</PillToolbarButton>
@ -108,7 +109,7 @@
{#if showFilterDropdown}
<div class="filter-dropdown" class:vertical onclick={(e) => e.stopPropagation()}>
<div class="filter-section">
<div class="filter-section-header">Priorität</div>
<div class="filter-section-header">{$t('task.priority')}</div>
<div class="filter-chips">
{#each priorities as priority}
<button
@ -126,7 +127,7 @@
{#if activeFilterCount > 0}
<button type="button" class="clear-filters-btn" onclick={clearAllFilters}>
Filter zurücksetzen
{$t('filters.resetAll')}
</button>
{/if}
</div>
@ -153,7 +154,7 @@
<PillToolbarButton
onclick={onToggleShowCompleted}
active={showCompleted}
title={showCompleted ? 'Erledigte ausblenden' : 'Erledigte anzeigen'}
title={showCompleted ? $t('filters.hideCompleted') : $t('filters.showCompleted')}
>
<CheckCircle size={20} />
</PillToolbarButton>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { DurationUnit, EffectiveDuration } from '@todo/shared';
import { t } from 'svelte-i18n';
import { X } from '@manacore/shared-icons';
interface Props {
@ -24,11 +25,11 @@
{ label: '2d', value: 2, unit: 'days' },
];
const unitOptions: { value: DurationUnit; label: string }[] = [
{ value: 'minutes', label: 'Minuten' },
{ value: 'hours', label: 'Stunden' },
{ value: 'days', label: 'Tage' },
];
let unitOptions = $derived([
{ value: 'minutes' as DurationUnit, label: $t('durationPicker.minutes') },
{ value: 'hours' as DurationUnit, label: $t('durationPicker.hours') },
{ value: 'days' as DurationUnit, label: $t('durationPicker.days') },
]);
// Sync custom inputs with value prop
$effect(() => {
@ -96,7 +97,7 @@
...
</button>
{#if value !== null}
<button type="button" class="duration-clear" onclick={clear} title="Zurücksetzen">
<button type="button" class="duration-clear" onclick={clear} title={$t('common.reset')}>
<X size={16} />
</button>
{/if}
@ -109,7 +110,7 @@
class="duration-input"
bind:value={customValue}
oninput={handleCustomChange}
placeholder="Wert"
placeholder={$t('durationPicker.value')}
min="1"
/>
<select class="duration-unit" bind:value={customUnit} onchange={handleCustomChange}>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { X } from '@manacore/shared-icons';
interface Props {
@ -39,7 +40,12 @@
</button>
{/each}
{#if value !== null}
<button type="button" class="fun-rating-clear" onclick={handleClear} title="Zurücksetzen">
<button
type="button"
class="fun-rating-clear"
onclick={handleClear}
title={$t('common.reset')}
>
<X size={16} />
</button>
{/if}

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { X } from '@manacore/shared-icons';
interface Props {
@ -32,7 +33,7 @@
</button>
{/each}
{#if value !== null}
<button type="button" class="storypoint-clear" onclick={handleClear} title="Zurücksetzen">
<button type="button" class="storypoint-clear" onclick={handleClear} title={$t('common.reset')}>
<X size={16} />
</button>
{/if}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { getContext } from 'svelte';
import { t } from 'svelte-i18n';
import type { Tag } from '@manacore/shared-tags';
import { CaretDown, Check } from '@manacore/shared-icons';
@ -37,7 +38,7 @@
<div class="tag-selector">
<button type="button" class="tag-trigger" onclick={handleTriggerClick}>
{#if selectedIds.length === 0}
<span class="text-muted">Tags auswählen...</span>
<span class="text-muted">{$t('tags.selectTags')}</span>
{:else}
<div class="selected-tags">
{#each selectedIds.slice(0, 3) as tagId}
@ -75,7 +76,7 @@
</button>
{/each}
{#if tagsCtx.value.length === 0}
<div class="no-tags">Keine Tags vorhanden</div>
<div class="no-tags">{$t('tags.noTagsAvailable')}</div>
{/if}
</div>
{/if}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { Task, Subtask } from '@todo/shared';
import { t } from 'svelte-i18n';
import { isToday, isPast } from 'date-fns';
import { formatDueDate } from '$lib/utils/date-display';
import { getSubtaskProgress } from '$lib/utils/task-helpers';
@ -166,7 +167,9 @@
const current = task.subtasks ?? [];
untrack(() => {
const newIds = new Set(current.map((s) => s.id));
const oldIds = new Set(subtaskItems.filter((s) => s.id !== SHADOW_PLACEHOLDER_ITEM_ID).map((s) => s.id));
const oldIds = new Set(
subtaskItems.filter((s) => s.id !== SHADOW_PLACEHOLDER_ITEM_ID).map((s) => s.id)
);
const idsChanged = newIds.size !== oldIds.size || current.some((s) => !oldIds.has(s.id));
if (idsChanged) {
subtaskItems = [...current];
@ -186,7 +189,9 @@
subtaskItems = e.detail.items.filter((s) => s.id !== SHADOW_PLACEHOLDER_ITEM_ID);
onSave?.({ subtasks: subtaskItems });
subtaskDropInProgress = true;
setTimeout(() => { subtaskDropInProgress = false; }, 500);
setTimeout(() => {
subtaskDropInProgress = false;
}, 500);
}
function toggleSubtask(subtaskId: string) {
@ -244,8 +249,8 @@
spellcheck="false"
onclick={(e) => e.stopPropagation()}
onkeydown={handleTitleKeydown}
onblur={handleTitleBlur}
>{task.title}</span>
onblur={handleTitleBlur}>{task.title}</span
>
<!-- Meta info -->
{#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)}
@ -283,7 +288,7 @@
type="button"
class="detail-btn"
onclick={handleOpenModal}
title="Details öffnen"
title={$t('taskForm.openDetails')}
tabindex="-1"
>
<ArrowsOutSimple size={14} />
@ -293,7 +298,10 @@
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
<div class="contacts-display">
{#if task.metadata?.assignee}
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
<div
class="assignee-avatar"
title={$t('kanban.assignedTo', { values: { name: task.metadata.assignee.displayName } })}
>
<ContactAvatar
name={task.metadata.assignee.displayName}
photoUrl={task.metadata.assignee.photoUrl}
@ -304,7 +312,10 @@
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
<div class="involved-avatars">
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
<div
class="involved-avatar"
title={$t('kanban.involvedContact', { values: { name: contact.displayName } })}
>
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
</div>
{/each}
@ -322,7 +333,12 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="subtasks-inline"
use:dndzone={{ items: subtaskItems, flipDurationMs: 150, dropTargetStyle: {}, type: 'subtask-inline' }}
use:dndzone={{
items: subtaskItems,
flipDurationMs: 150,
dropTargetStyle: {},
type: 'subtask-inline',
}}
onconsider={handleSubtaskConsider}
onfinalize={handleSubtaskFinalize}
>
@ -363,16 +379,16 @@
>
<button class="context-item" onclick={handleContextEdit}>
<Note size={20} class="context-icon" />
Bearbeiten
{$t('kanban.edit')}
</button>
<button class="context-item" onclick={handleContextToggleComplete}>
<ArrowsClockwise size={20} class="context-icon" />
{task.isCompleted ? 'Wiederherstellen' : 'Erledigen'}
{task.isCompleted ? $t('kanban.restore') : $t('kanban.complete')}
</button>
<div class="context-divider"></div>
<button class="context-item danger" onclick={handleContextDelete}>
<Trash size={20} class="context-icon" />
Löschen
{$t('common.delete')}
</button>
</div>
{/if}
@ -392,10 +408,10 @@
onClose={() => (showDeleteConfirm = false)}
onConfirm={confirmDelete}
variant="danger"
title="Aufgabe löschen?"
message="Diese Aufgabe wird unwiderruflich gelöscht."
confirmLabel="Löschen"
cancelLabel="Abbrechen"
title={$t('kanban.deleteTitle')}
message={$t('kanban.deleteMessage')}
confirmLabel={$t('common.delete')}
cancelLabel={$t('common.cancel')}
/>
<style>
@ -567,7 +583,6 @@
opacity: 0.5;
}
/* Meta info */
.task-meta {
display: flex;

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { Plus, X } from '@manacore/shared-icons';
interface Props {
@ -57,7 +58,7 @@
onclick={handleSubmit}
>
<Plus size={14} />
Hinzufügen
{$t('kanban.add')}
</button>
<button
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded-full transition-colors"
@ -81,7 +82,7 @@
>
<Plus size={14} />
</div>
<span class="group-hover:text-foreground transition-colors">Aufgabe hinzufügen</span>
<span class="group-hover:text-foreground transition-colors">{$t('kanban.addTask')}</span>
</button>
{/if}
</div>

View file

@ -83,7 +83,9 @@
"error": "Fehler",
"success": "Erfolgreich",
"loading": "Laden...",
"noResults": "Keine Ergebnisse"
"noResults": "Keine Ergebnisse",
"reset": "Zurücksetzen",
"filter": "Filter"
},
"errors": {
"loadTasks": "Aufgaben konnten nicht geladen werden",
@ -104,5 +106,123 @@
"taskCompleted": "Aufgabe erledigt",
"projectCreated": "Projekt erstellt",
"labelCreated": "Label erstellt"
},
"taskForm": {
"titlePlaceholder": "Aufgabentitel...",
"addDescription": "Beschreibung hinzufügen...",
"description": "Beschreibung",
"scheduling": "Zeitplanung",
"due": "Fällig",
"dueDate": "Fälligkeit",
"time": "Uhrzeit",
"start": "Start",
"startDate": "Startdatum",
"status": "Status",
"tags": "Tags",
"subtasks": "Subtasks",
"recurrence": "Wiederholung",
"assignee": "Zuständig",
"involved": "Beteiligte",
"assignPlaceholder": "Zuweisen...",
"assignLabel": "Zuweisen",
"assignPerson": "Person zuweisen...",
"addPeoplePlaceholder": "Personen hinzufügen...",
"addPersonLabel": "Person hinzufügen",
"addPlaceholder": "Hinzufügen...",
"addLabel": "Hinzufügen",
"nameOrEmail": "Name oder E-Mail...",
"storypoints": "Storypoints",
"duration": "Dauer",
"fun": "Spaß",
"links": "Verknüpfungen",
"addLink": "+ Verknüpfen",
"confirmDelete": "Wirklich löschen?",
"yesDelete": "Ja, löschen",
"openDetails": "Details öffnen",
"clickForCreatedDate": "Klicken für Erstellungsdatum",
"created": "Erstellt",
"completed": "Erledigt"
},
"filters": {
"clearFilter": "Filter löschen",
"manageTags": "Tags verwalten",
"hideCompleted": "Erledigte ausblenden",
"showCompleted": "Erledigte anzeigen",
"searchTasks": "Aufgaben suchen...",
"resetFilters": "Zurücksetzen",
"select": "Auswählen",
"noTagsAvailable": "Keine Tags vorhanden",
"date": "Datum",
"priorityShort": "Priorit.",
"name": "Name",
"sortByDueDate": "Nach Fälligkeitsdatum sortieren",
"sortByPriority": "Nach Priorität sortieren",
"sortAlphabetical": "Alphabetisch sortieren",
"resetAll": "Filter zurücksetzen"
},
"tags": {
"allTags": "Alle Tags",
"showAllTags": "Alle Tags anzeigen",
"noTagsAvailable": "Keine Tags vorhanden",
"createTag": "Tag erstellen",
"newTag": "Neuer Tag",
"editTag": "Tag bearbeiten",
"deleteTag": "Tag löschen",
"tagName": "Tag Name",
"searchTags": "Tags suchen...",
"clearSearch": "Suche leeren",
"createShort": "+ Erstellen",
"noTagsFound": "Keine Tags gefunden für \"{query}\"",
"selectTags": "Tags auswählen...",
"tagsLabel": "Tags:",
"filterLabel": "Filter:",
"sortBy": "Nach {field} sortieren"
},
"subtasks": {
"dragToSort": "Ziehen zum Sortieren",
"addPlaceholder": "Subtask hinzufügen...",
"add": "Hinzufügen"
},
"reminders": {
"label": "Erinnerung",
"none": "Keine",
"atTime": "Zur Fälligkeitszeit",
"5min": "5 Minuten vorher",
"15min": "15 Minuten vorher",
"30min": "30 Minuten vorher",
"1hour": "1 Stunde vorher",
"2hours": "2 Stunden vorher",
"1day": "1 Tag vorher",
"2days": "2 Tage vorher",
"1week": "1 Woche vorher",
"added": "Erinnerung hinzugefügt",
"removed": "Erinnerung entfernt",
"requiresDueDate": "Erinnerungen brauchen ein Fälligkeitsdatum",
"type": "Typ",
"push": "Push",
"email": "E-Mail",
"both": "Push & E-Mail"
},
"durationPicker": {
"minutes": "Minuten",
"hours": "Stunden",
"days": "Tage",
"value": "Wert"
},
"kanban": {
"newTask": "Neue Aufgabe...",
"addTask": "Aufgabe hinzufügen",
"add": "Hinzufügen",
"edit": "Bearbeiten",
"restore": "Wiederherstellen",
"complete": "Erledigen",
"deleteTitle": "Aufgabe löschen?",
"deleteMessage": "Diese Aufgabe wird unwiderruflich gelöscht.",
"assignedTo": "Zuständig: {name}",
"involvedContact": "Beteiligt: {name}"
},
"toolbar": {
"taskOptions": "Aufgaben-Optionen",
"switchView": "Ansicht wechseln"
}
}

View file

@ -83,7 +83,9 @@
"error": "Error",
"success": "Success",
"loading": "Loading...",
"noResults": "No results"
"noResults": "No results",
"reset": "Reset",
"filter": "Filter"
},
"errors": {
"loadTasks": "Failed to load tasks",
@ -104,5 +106,123 @@
"taskCompleted": "Task completed",
"projectCreated": "Project created",
"labelCreated": "Label created"
},
"taskForm": {
"titlePlaceholder": "Task title...",
"addDescription": "Add description...",
"description": "Description",
"scheduling": "Scheduling",
"due": "Due",
"dueDate": "Due date",
"time": "Time",
"start": "Start",
"startDate": "Start date",
"status": "Status",
"tags": "Tags",
"subtasks": "Subtasks",
"recurrence": "Recurrence",
"assignee": "Assignee",
"involved": "Involved",
"assignPlaceholder": "Assign...",
"assignLabel": "Assign",
"assignPerson": "Assign person...",
"addPeoplePlaceholder": "Add people...",
"addPersonLabel": "Add person",
"addPlaceholder": "Add...",
"addLabel": "Add",
"nameOrEmail": "Name or email...",
"storypoints": "Story points",
"duration": "Duration",
"fun": "Fun",
"links": "Links",
"addLink": "+ Link",
"confirmDelete": "Really delete?",
"yesDelete": "Yes, delete",
"openDetails": "Open details",
"clickForCreatedDate": "Click for created date",
"created": "Created",
"completed": "Completed"
},
"filters": {
"clearFilter": "Clear filter",
"manageTags": "Manage tags",
"hideCompleted": "Hide completed",
"showCompleted": "Show completed",
"searchTasks": "Search tasks...",
"resetFilters": "Reset",
"select": "Select",
"noTagsAvailable": "No tags available",
"date": "Date",
"priorityShort": "Priority",
"name": "Name",
"sortByDueDate": "Sort by due date",
"sortByPriority": "Sort by priority",
"sortAlphabetical": "Sort alphabetically",
"resetAll": "Reset filters"
},
"tags": {
"allTags": "All tags",
"showAllTags": "Show all tags",
"noTagsAvailable": "No tags available",
"createTag": "Create tag",
"newTag": "New tag",
"editTag": "Edit tag",
"deleteTag": "Delete tag",
"tagName": "Tag name",
"searchTags": "Search tags...",
"clearSearch": "Clear search",
"createShort": "+ Create",
"noTagsFound": "No tags found for \"{query}\"",
"selectTags": "Select tags...",
"tagsLabel": "Tags:",
"filterLabel": "Filter:",
"sortBy": "Sort by {field}"
},
"subtasks": {
"dragToSort": "Drag to sort",
"addPlaceholder": "Add subtask...",
"add": "Add"
},
"reminders": {
"label": "Reminder",
"none": "None",
"atTime": "At time of task",
"5min": "5 minutes before",
"15min": "15 minutes before",
"30min": "30 minutes before",
"1hour": "1 hour before",
"2hours": "2 hours before",
"1day": "1 day before",
"2days": "2 days before",
"1week": "1 week before",
"added": "Reminder added",
"removed": "Reminder removed",
"requiresDueDate": "Reminders require a due date",
"type": "Type",
"push": "Push",
"email": "Email",
"both": "Push & Email"
},
"durationPicker": {
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"value": "Value"
},
"kanban": {
"newTask": "New task...",
"addTask": "Add task",
"add": "Add",
"edit": "Edit",
"restore": "Restore",
"complete": "Complete",
"deleteTitle": "Delete task?",
"deleteMessage": "This task will be permanently deleted.",
"assignedTo": "Assigned to: {name}",
"involvedContact": "Involved: {name}"
},
"toolbar": {
"taskOptions": "Task options",
"switchView": "Switch view"
}
}

View file

@ -83,7 +83,9 @@
"error": "Error",
"success": "Éxito",
"loading": "Cargando...",
"noResults": "Sin resultados"
"noResults": "Sin resultados",
"reset": "Restablecer",
"filter": "Filtro"
},
"errors": {
"loadTasks": "No se pudieron cargar las tareas",
@ -104,5 +106,123 @@
"taskCompleted": "Tarea completada",
"projectCreated": "Proyecto creado",
"labelCreated": "Etiqueta creada"
},
"taskForm": {
"titlePlaceholder": "Título de la tarea...",
"addDescription": "Añadir descripción...",
"description": "Descripción",
"scheduling": "Programación",
"due": "Vencimiento",
"dueDate": "Fecha de vencimiento",
"time": "Hora",
"start": "Inicio",
"startDate": "Fecha de inicio",
"status": "Estado",
"tags": "Tags",
"subtasks": "Subtareas",
"recurrence": "Recurrencia",
"assignee": "Responsable",
"involved": "Participantes",
"assignPlaceholder": "Asignar...",
"assignLabel": "Asignar",
"assignPerson": "Asignar persona...",
"addPeoplePlaceholder": "Añadir personas...",
"addPersonLabel": "Añadir persona",
"addPlaceholder": "Añadir...",
"addLabel": "Añadir",
"nameOrEmail": "Nombre o correo...",
"storypoints": "Story points",
"duration": "Duración",
"fun": "Diversión",
"links": "Enlaces",
"addLink": "+ Enlazar",
"confirmDelete": "¿Eliminar realmente?",
"yesDelete": "Sí, eliminar",
"openDetails": "Abrir detalles",
"clickForCreatedDate": "Clic para fecha de creación",
"created": "Creado",
"completed": "Completado"
},
"filters": {
"clearFilter": "Borrar filtro",
"manageTags": "Gestionar tags",
"hideCompleted": "Ocultar completadas",
"showCompleted": "Mostrar completadas",
"searchTasks": "Buscar tareas...",
"resetFilters": "Restablecer",
"select": "Seleccionar",
"noTagsAvailable": "No hay tags disponibles",
"date": "Fecha",
"priorityShort": "Prioridad",
"name": "Nombre",
"sortByDueDate": "Ordenar por fecha de vencimiento",
"sortByPriority": "Ordenar por prioridad",
"sortAlphabetical": "Ordenar alfabéticamente",
"resetAll": "Restablecer filtros"
},
"tags": {
"allTags": "Todos los tags",
"showAllTags": "Mostrar todos los tags",
"noTagsAvailable": "No hay tags disponibles",
"createTag": "Crear tag",
"newTag": "Nuevo tag",
"editTag": "Editar tag",
"deleteTag": "Eliminar tag",
"tagName": "Nombre del tag",
"searchTags": "Buscar tags...",
"clearSearch": "Borrar búsqueda",
"createShort": "+ Crear",
"noTagsFound": "No se encontraron tags para \"{query}\"",
"selectTags": "Seleccionar tags...",
"tagsLabel": "Tags:",
"filterLabel": "Filtro:",
"sortBy": "Ordenar por {field}"
},
"subtasks": {
"dragToSort": "Arrastrar para ordenar",
"addPlaceholder": "Añadir subtarea...",
"add": "Añadir"
},
"reminders": {
"label": "Recordatorio",
"none": "Ninguno",
"atTime": "A la hora de la tarea",
"5min": "5 minutos antes",
"15min": "15 minutos antes",
"30min": "30 minutos antes",
"1hour": "1 hora antes",
"2hours": "2 horas antes",
"1day": "1 día antes",
"2days": "2 días antes",
"1week": "1 semana antes",
"added": "Recordatorio añadido",
"removed": "Recordatorio eliminado",
"requiresDueDate": "Los recordatorios requieren una fecha de vencimiento",
"type": "Tipo",
"push": "Push",
"email": "Correo",
"both": "Push y Correo"
},
"durationPicker": {
"minutes": "Minutos",
"hours": "Horas",
"days": "Días",
"value": "Valor"
},
"kanban": {
"newTask": "Nueva tarea...",
"addTask": "Añadir tarea",
"add": "Añadir",
"edit": "Editar",
"restore": "Restaurar",
"complete": "Completar",
"deleteTitle": "¿Eliminar tarea?",
"deleteMessage": "Esta tarea se eliminará permanentemente.",
"assignedTo": "Responsable: {name}",
"involvedContact": "Participante: {name}"
},
"toolbar": {
"taskOptions": "Opciones de tareas",
"switchView": "Cambiar vista"
}
}

View file

@ -83,7 +83,9 @@
"error": "Erreur",
"success": "Succès",
"loading": "Chargement...",
"noResults": "Aucun résultat"
"noResults": "Aucun résultat",
"reset": "Réinitialiser",
"filter": "Filtre"
},
"errors": {
"loadTasks": "Impossible de charger les tâches",
@ -104,5 +106,123 @@
"taskCompleted": "Tâche terminée",
"projectCreated": "Projet créé",
"labelCreated": "Label créé"
},
"taskForm": {
"titlePlaceholder": "Titre de la tâche...",
"addDescription": "Ajouter une description...",
"description": "Description",
"scheduling": "Planification",
"due": "Échéance",
"dueDate": "Date d'échéance",
"time": "Heure",
"start": "Début",
"startDate": "Date de début",
"status": "Statut",
"tags": "Tags",
"subtasks": "Sous-tâches",
"recurrence": "Récurrence",
"assignee": "Responsable",
"involved": "Participants",
"assignPlaceholder": "Assigner...",
"assignLabel": "Assigner",
"assignPerson": "Assigner une personne...",
"addPeoplePlaceholder": "Ajouter des personnes...",
"addPersonLabel": "Ajouter une personne",
"addPlaceholder": "Ajouter...",
"addLabel": "Ajouter",
"nameOrEmail": "Nom ou e-mail...",
"storypoints": "Story points",
"duration": "Durée",
"fun": "Plaisir",
"links": "Liens",
"addLink": "+ Lier",
"confirmDelete": "Vraiment supprimer ?",
"yesDelete": "Oui, supprimer",
"openDetails": "Ouvrir les détails",
"clickForCreatedDate": "Cliquer pour la date de création",
"created": "Créé",
"completed": "Terminé"
},
"filters": {
"clearFilter": "Effacer le filtre",
"manageTags": "Gérer les tags",
"hideCompleted": "Masquer les terminées",
"showCompleted": "Afficher les terminées",
"searchTasks": "Rechercher des tâches...",
"resetFilters": "Réinitialiser",
"select": "Sélectionner",
"noTagsAvailable": "Aucun tag disponible",
"date": "Date",
"priorityShort": "Priorité",
"name": "Nom",
"sortByDueDate": "Trier par date d'échéance",
"sortByPriority": "Trier par priorité",
"sortAlphabetical": "Trier par ordre alphabétique",
"resetAll": "Réinitialiser les filtres"
},
"tags": {
"allTags": "Tous les tags",
"showAllTags": "Afficher tous les tags",
"noTagsAvailable": "Aucun tag disponible",
"createTag": "Créer un tag",
"newTag": "Nouveau tag",
"editTag": "Modifier le tag",
"deleteTag": "Supprimer le tag",
"tagName": "Nom du tag",
"searchTags": "Rechercher des tags...",
"clearSearch": "Effacer la recherche",
"createShort": "+ Créer",
"noTagsFound": "Aucun tag trouvé pour \"{query}\"",
"selectTags": "Sélectionner des tags...",
"tagsLabel": "Tags :",
"filterLabel": "Filtre :",
"sortBy": "Trier par {field}"
},
"subtasks": {
"dragToSort": "Glisser pour trier",
"addPlaceholder": "Ajouter une sous-tâche...",
"add": "Ajouter"
},
"reminders": {
"label": "Rappel",
"none": "Aucun",
"atTime": "À l'heure de la tâche",
"5min": "5 minutes avant",
"15min": "15 minutes avant",
"30min": "30 minutes avant",
"1hour": "1 heure avant",
"2hours": "2 heures avant",
"1day": "1 jour avant",
"2days": "2 jours avant",
"1week": "1 semaine avant",
"added": "Rappel ajouté",
"removed": "Rappel supprimé",
"requiresDueDate": "Les rappels nécessitent une date d'échéance",
"type": "Type",
"push": "Push",
"email": "E-mail",
"both": "Push & E-mail"
},
"durationPicker": {
"minutes": "Minutes",
"hours": "Heures",
"days": "Jours",
"value": "Valeur"
},
"kanban": {
"newTask": "Nouvelle tâche...",
"addTask": "Ajouter une tâche",
"add": "Ajouter",
"edit": "Modifier",
"restore": "Restaurer",
"complete": "Terminer",
"deleteTitle": "Supprimer la tâche ?",
"deleteMessage": "Cette tâche sera supprimée définitivement.",
"assignedTo": "Responsable : {name}",
"involvedContact": "Participant : {name}"
},
"toolbar": {
"taskOptions": "Options des tâches",
"switchView": "Changer de vue"
}
}

View file

@ -83,7 +83,9 @@
"error": "Errore",
"success": "Successo",
"loading": "Caricamento...",
"noResults": "Nessun risultato"
"noResults": "Nessun risultato",
"reset": "Reimposta",
"filter": "Filtro"
},
"errors": {
"loadTasks": "Impossibile caricare le attività",
@ -104,5 +106,123 @@
"taskCompleted": "Attività completata",
"projectCreated": "Progetto creato",
"labelCreated": "Etichetta creata"
},
"taskForm": {
"titlePlaceholder": "Titolo dell'attività...",
"addDescription": "Aggiungi descrizione...",
"description": "Descrizione",
"scheduling": "Pianificazione",
"due": "Scadenza",
"dueDate": "Data di scadenza",
"time": "Orario",
"start": "Inizio",
"startDate": "Data di inizio",
"status": "Stato",
"tags": "Tags",
"subtasks": "Sotto-attività",
"recurrence": "Ricorrenza",
"assignee": "Responsabile",
"involved": "Partecipanti",
"assignPlaceholder": "Assegna...",
"assignLabel": "Assegna",
"assignPerson": "Assegna persona...",
"addPeoplePlaceholder": "Aggiungi persone...",
"addPersonLabel": "Aggiungi persona",
"addPlaceholder": "Aggiungi...",
"addLabel": "Aggiungi",
"nameOrEmail": "Nome o e-mail...",
"storypoints": "Story points",
"duration": "Durata",
"fun": "Divertimento",
"links": "Collegamento",
"addLink": "+ Collega",
"confirmDelete": "Eliminare davvero?",
"yesDelete": "Sì, elimina",
"openDetails": "Apri dettagli",
"clickForCreatedDate": "Clicca per data di creazione",
"created": "Creato",
"completed": "Completato"
},
"filters": {
"clearFilter": "Cancella filtro",
"manageTags": "Gestisci tags",
"hideCompleted": "Nascondi completate",
"showCompleted": "Mostra completate",
"searchTasks": "Cerca attività...",
"resetFilters": "Reimposta",
"select": "Seleziona",
"noTagsAvailable": "Nessun tag disponibile",
"date": "Data",
"priorityShort": "Priorità",
"name": "Nome",
"sortByDueDate": "Ordina per scadenza",
"sortByPriority": "Ordina per priorità",
"sortAlphabetical": "Ordina alfabeticamente",
"resetAll": "Reimposta filtri"
},
"tags": {
"allTags": "Tutti i tags",
"showAllTags": "Mostra tutti i tags",
"noTagsAvailable": "Nessun tag disponibile",
"createTag": "Crea tag",
"newTag": "Nuovo tag",
"editTag": "Modifica tag",
"deleteTag": "Elimina tag",
"tagName": "Nome del tag",
"searchTags": "Cerca tags...",
"clearSearch": "Cancella ricerca",
"createShort": "+ Crea",
"noTagsFound": "Nessun tag trovato per \"{query}\"",
"selectTags": "Seleziona tags...",
"tagsLabel": "Tags:",
"filterLabel": "Filtro:",
"sortBy": "Ordina per {field}"
},
"subtasks": {
"dragToSort": "Trascina per ordinare",
"addPlaceholder": "Aggiungi sotto-attività...",
"add": "Aggiungi"
},
"reminders": {
"label": "Promemoria",
"none": "Nessuno",
"atTime": "All'ora dell'attività",
"5min": "5 minuti prima",
"15min": "15 minuti prima",
"30min": "30 minuti prima",
"1hour": "1 ora prima",
"2hours": "2 ore prima",
"1day": "1 giorno prima",
"2days": "2 giorni prima",
"1week": "1 settimana prima",
"added": "Promemoria aggiunto",
"removed": "Promemoria rimosso",
"requiresDueDate": "I promemoria richiedono una data di scadenza",
"type": "Tipo",
"push": "Push",
"email": "E-mail",
"both": "Push ed E-mail"
},
"durationPicker": {
"minutes": "Minuti",
"hours": "Ore",
"days": "Giorni",
"value": "Valore"
},
"kanban": {
"newTask": "Nuova attività...",
"addTask": "Aggiungi attività",
"add": "Aggiungi",
"edit": "Modifica",
"restore": "Ripristina",
"complete": "Completa",
"deleteTitle": "Eliminare attività?",
"deleteMessage": "Questa attività verrà eliminata definitivamente.",
"assignedTo": "Responsabile: {name}",
"involvedContact": "Partecipante: {name}"
},
"toolbar": {
"taskOptions": "Opzioni attività",
"switchView": "Cambia vista"
}
}

View file

@ -269,6 +269,7 @@ services:
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
MANA_CREDITS_URL: http://mana-credits:3061
MANA_SUBSCRIPTIONS_URL: http://mana-subscriptions:3063
SYNC_DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-${JWT_SECRET:-your-jwt-secret-change-me}}
SMTP_HOST: smtp-relay.brevo.com
SMTP_PORT: 587

View file

@ -0,0 +1,172 @@
{
"common": {
"actions": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"create": "Crear",
"update": "Actualizar",
"close": "Cerrar",
"confirm": "Confirmar",
"submit": "Enviar",
"back": "Atrás",
"next": "Siguiente",
"done": "Hecho",
"retry": "Reintentar",
"refresh": "Actualizar",
"search": "Buscar",
"filter": "Filtrar",
"sort": "Ordenar",
"share": "Compartir",
"copy": "Copiar",
"download": "Descargar",
"upload": "Subir",
"select": "Seleccionar",
"clear": "Vaciar",
"reset": "Restablecer",
"apply": "Aplicar",
"continue": "Continuar",
"skip": "Omitir",
"yes": "Sí",
"no": "No",
"ok": "OK"
},
"labels": {
"loading": "Cargando...",
"saving": "Guardando...",
"deleting": "Eliminando...",
"processing": "Procesando...",
"uploading": "Subiendo...",
"downloading": "Descargando...",
"searching": "Buscando...",
"noResults": "No se encontraron resultados",
"noData": "No hay datos disponibles",
"empty": "Vacío",
"all": "Todos",
"none": "Ninguno",
"other": "Otro",
"more": "Más",
"less": "Menos",
"showMore": "Mostrar más",
"showLess": "Mostrar menos",
"viewAll": "Ver todo",
"required": "Obligatorio",
"optional": "Opcional",
"new": "Nuevo",
"recent": "Reciente",
"popular": "Popular",
"featured": "Destacado"
},
"time": {
"now": "Ahora",
"today": "Hoy",
"yesterday": "Ayer",
"tomorrow": "Mañana",
"thisWeek": "Esta semana",
"lastWeek": "La semana pasada",
"thisMonth": "Este mes",
"lastMonth": "El mes pasado",
"thisYear": "Este año",
"ago": "hace",
"in": "en"
},
"status": {
"active": "Activo",
"inactive": "Inactivo",
"pending": "Pendiente",
"completed": "Completado",
"failed": "Fallido",
"cancelled": "Cancelado",
"success": "Éxito",
"error": "Error",
"warning": "Advertencia",
"info": "Info"
}
},
"errors": {
"generic": "Algo salió mal. Por favor, inténtalo de nuevo.",
"network": "Error de red. Por favor, comprueba tu conexión.",
"timeout": "Tiempo de espera agotado. Por favor, inténtalo de nuevo.",
"notFound": "El elemento solicitado no fue encontrado.",
"unauthorized": "No tienes autorización para realizar esta acción.",
"forbidden": "Acceso denegado.",
"serverError": "Error del servidor. Por favor, inténtalo más tarde.",
"validation": "Por favor, revisa tu entrada e inténtalo de nuevo.",
"unknown": "Ocurrió un error desconocido.",
"offline": "Estás sin conexión. Por favor, comprueba tu conexión a internet.",
"sessionExpired": "Tu sesión ha expirado. Por favor, inicia sesión de nuevo.",
"rateLimited": "Demasiadas solicitudes. Por favor, espera un momento e inténtalo de nuevo."
},
"validation": {
"required": "Este campo es obligatorio",
"email": "Por favor, introduce una dirección de correo válida",
"minLength": "Debe tener al menos {min} caracteres",
"maxLength": "Debe tener como máximo {max} caracteres",
"min": "Debe ser al menos {min}",
"max": "Debe ser como máximo {max}",
"pattern": "Formato no válido",
"match": "Los campos no coinciden",
"unique": "Este valor ya está en uso",
"invalid": "Valor no válido",
"url": "Por favor, introduce una URL válida",
"phone": "Por favor, introduce un número de teléfono válido",
"number": "Por favor, introduce un número válido",
"integer": "Por favor, introduce un número entero",
"positive": "Debe ser un número positivo",
"date": "Por favor, introduce una fecha válida",
"futureDate": "La fecha debe ser en el futuro",
"pastDate": "La fecha debe ser en el pasado",
"password": {
"minLength": "La contraseña debe tener al menos {min} caracteres",
"uppercase": "La contraseña debe contener una letra mayúscula",
"lowercase": "La contraseña debe contener una letra minúscula",
"number": "La contraseña debe contener un número",
"special": "La contraseña debe contener un carácter especial",
"weak": "La contraseña es demasiado débil"
}
},
"auth": {
"signIn": "Iniciar sesión",
"signOut": "Cerrar sesión",
"signUp": "Registrarse",
"forgotPassword": "¿Olvidaste tu contraseña?",
"resetPassword": "Restablecer contraseña",
"changePassword": "Cambiar contraseña",
"email": "Correo electrónico",
"password": "Contraseña",
"confirmPassword": "Confirmar contraseña",
"rememberMe": "Recordarme",
"orContinueWith": "O continuar con",
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"dontHaveAccount": "¿No tienes una cuenta?",
"errors": {
"invalidCredentials": "Correo electrónico o contraseña no válidos",
"emailInUse": "Este correo electrónico ya está en uso",
"weakPassword": "La contraseña es demasiado débil",
"userNotFound": "Usuario no encontrado",
"tooManyAttempts": "Demasiados intentos. Por favor, inténtalo más tarde."
}
},
"settings": {
"title": "Ajustes",
"account": "Cuenta",
"profile": "Perfil",
"preferences": "Preferencias",
"notifications": "Notificaciones",
"privacy": "Privacidad",
"security": "Seguridad",
"language": "Idioma",
"theme": "Tema",
"appearance": "Apariencia",
"darkMode": "Modo oscuro",
"lightMode": "Modo claro",
"systemDefault": "Predeterminado del sistema",
"about": "Acerca de",
"help": "Ayuda",
"feedback": "Comentarios",
"terms": "Términos de servicio",
"privacyPolicy": "Política de privacidad",
"version": "Versión"
}
}

View file

@ -0,0 +1,172 @@
{
"common": {
"actions": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"create": "Créer",
"update": "Mettre à jour",
"close": "Fermer",
"confirm": "Confirmer",
"submit": "Envoyer",
"back": "Retour",
"next": "Suivant",
"done": "Terminé",
"retry": "Réessayer",
"refresh": "Actualiser",
"search": "Rechercher",
"filter": "Filtrer",
"sort": "Trier",
"share": "Partager",
"copy": "Copier",
"download": "Télécharger",
"upload": "Importer",
"select": "Sélectionner",
"clear": "Vider",
"reset": "Réinitialiser",
"apply": "Appliquer",
"continue": "Continuer",
"skip": "Passer",
"yes": "Oui",
"no": "Non",
"ok": "OK"
},
"labels": {
"loading": "Chargement...",
"saving": "Enregistrement...",
"deleting": "Suppression...",
"processing": "Traitement...",
"uploading": "Importation...",
"downloading": "Téléchargement...",
"searching": "Recherche...",
"noResults": "Aucun résultat trouvé",
"noData": "Aucune donnée disponible",
"empty": "Vide",
"all": "Tous",
"none": "Aucun",
"other": "Autre",
"more": "Plus",
"less": "Moins",
"showMore": "Afficher plus",
"showLess": "Afficher moins",
"viewAll": "Tout afficher",
"required": "Obligatoire",
"optional": "Facultatif",
"new": "Nouveau",
"recent": "Récent",
"popular": "Populaire",
"featured": "En vedette"
},
"time": {
"now": "Maintenant",
"today": "Aujourd'hui",
"yesterday": "Hier",
"tomorrow": "Demain",
"thisWeek": "Cette semaine",
"lastWeek": "La semaine dernière",
"thisMonth": "Ce mois-ci",
"lastMonth": "Le mois dernier",
"thisYear": "Cette année",
"ago": "il y a",
"in": "dans"
},
"status": {
"active": "Actif",
"inactive": "Inactif",
"pending": "En attente",
"completed": "Terminé",
"failed": "Échoué",
"cancelled": "Annulé",
"success": "Succès",
"error": "Erreur",
"warning": "Avertissement",
"info": "Info"
}
},
"errors": {
"generic": "Une erreur s'est produite. Veuillez réessayer.",
"network": "Erreur réseau. Veuillez vérifier votre connexion.",
"timeout": "Délai d'attente dépassé. Veuillez réessayer.",
"notFound": "L'élément demandé n'a pas été trouvé.",
"unauthorized": "Vous n'êtes pas autorisé à effectuer cette action.",
"forbidden": "Accès refusé.",
"serverError": "Erreur serveur. Veuillez réessayer plus tard.",
"validation": "Veuillez vérifier votre saisie et réessayer.",
"unknown": "Une erreur inconnue s'est produite.",
"offline": "Vous êtes hors ligne. Veuillez vérifier votre connexion internet.",
"sessionExpired": "Votre session a expiré. Veuillez vous reconnecter.",
"rateLimited": "Trop de requêtes. Veuillez patienter un instant et réessayer."
},
"validation": {
"required": "Ce champ est obligatoire",
"email": "Veuillez saisir une adresse e-mail valide",
"minLength": "Doit contenir au moins {min} caractères",
"maxLength": "Doit contenir au maximum {max} caractères",
"min": "Doit être au moins {min}",
"max": "Doit être au maximum {max}",
"pattern": "Format non valide",
"match": "Les champs ne correspondent pas",
"unique": "Cette valeur est déjà utilisée",
"invalid": "Valeur non valide",
"url": "Veuillez saisir une URL valide",
"phone": "Veuillez saisir un numéro de téléphone valide",
"number": "Veuillez saisir un nombre valide",
"integer": "Veuillez saisir un nombre entier",
"positive": "Doit être un nombre positif",
"date": "Veuillez saisir une date valide",
"futureDate": "La date doit être dans le futur",
"pastDate": "La date doit être dans le passé",
"password": {
"minLength": "Le mot de passe doit contenir au moins {min} caractères",
"uppercase": "Le mot de passe doit contenir une lettre majuscule",
"lowercase": "Le mot de passe doit contenir une lettre minuscule",
"number": "Le mot de passe doit contenir un chiffre",
"special": "Le mot de passe doit contenir un caractère spécial",
"weak": "Le mot de passe est trop faible"
}
},
"auth": {
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"signUp": "S'inscrire",
"forgotPassword": "Mot de passe oublié ?",
"resetPassword": "Réinitialiser le mot de passe",
"changePassword": "Changer le mot de passe",
"email": "E-mail",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"rememberMe": "Se souvenir de moi",
"orContinueWith": "Ou continuer avec",
"alreadyHaveAccount": "Vous avez déjà un compte ?",
"dontHaveAccount": "Vous n'avez pas de compte ?",
"errors": {
"invalidCredentials": "E-mail ou mot de passe non valide",
"emailInUse": "Cet e-mail est déjà utilisé",
"weakPassword": "Le mot de passe est trop faible",
"userNotFound": "Utilisateur non trouvé",
"tooManyAttempts": "Trop de tentatives. Veuillez réessayer plus tard."
}
},
"settings": {
"title": "Paramètres",
"account": "Compte",
"profile": "Profil",
"preferences": "Préférences",
"notifications": "Notifications",
"privacy": "Confidentialité",
"security": "Sécurité",
"language": "Langue",
"theme": "Thème",
"appearance": "Apparence",
"darkMode": "Mode sombre",
"lightMode": "Mode clair",
"systemDefault": "Par défaut du système",
"about": "À propos",
"help": "Aide",
"feedback": "Commentaires",
"terms": "Conditions d'utilisation",
"privacyPolicy": "Politique de confidentialité",
"version": "Version"
}
}

View file

@ -4,8 +4,11 @@
import en from './en.json';
import de from './de.json';
import es from './es.json';
import fr from './fr.json';
import it from './it.json';
export { en, de };
export { en, de, es, fr, it };
/**
* Common translations type
@ -19,6 +22,12 @@ export function getCommonTranslations(locale: string): CommonTranslations {
switch (locale) {
case 'de':
return de;
case 'es':
return es;
case 'fr':
return fr;
case 'it':
return it;
case 'en':
default:
return en;

View file

@ -0,0 +1,172 @@
{
"common": {
"actions": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"create": "Crea",
"update": "Aggiorna",
"close": "Chiudi",
"confirm": "Conferma",
"submit": "Invia",
"back": "Indietro",
"next": "Avanti",
"done": "Fatto",
"retry": "Riprova",
"refresh": "Aggiorna",
"search": "Cerca",
"filter": "Filtra",
"sort": "Ordina",
"share": "Condividi",
"copy": "Copia",
"download": "Scarica",
"upload": "Carica",
"select": "Seleziona",
"clear": "Svuota",
"reset": "Reimposta",
"apply": "Applica",
"continue": "Continua",
"skip": "Salta",
"yes": "Sì",
"no": "No",
"ok": "OK"
},
"labels": {
"loading": "Caricamento...",
"saving": "Salvataggio...",
"deleting": "Eliminazione...",
"processing": "Elaborazione...",
"uploading": "Caricamento...",
"downloading": "Scaricamento...",
"searching": "Ricerca...",
"noResults": "Nessun risultato trovato",
"noData": "Nessun dato disponibile",
"empty": "Vuoto",
"all": "Tutti",
"none": "Nessuno",
"other": "Altro",
"more": "Di più",
"less": "Di meno",
"showMore": "Mostra di più",
"showLess": "Mostra di meno",
"viewAll": "Mostra tutto",
"required": "Obbligatorio",
"optional": "Facoltativo",
"new": "Nuovo",
"recent": "Recente",
"popular": "Popolare",
"featured": "In evidenza"
},
"time": {
"now": "Ora",
"today": "Oggi",
"yesterday": "Ieri",
"tomorrow": "Domani",
"thisWeek": "Questa settimana",
"lastWeek": "La settimana scorsa",
"thisMonth": "Questo mese",
"lastMonth": "Il mese scorso",
"thisYear": "Quest'anno",
"ago": "fa",
"in": "tra"
},
"status": {
"active": "Attivo",
"inactive": "Inattivo",
"pending": "In sospeso",
"completed": "Completato",
"failed": "Non riuscito",
"cancelled": "Annullato",
"success": "Successo",
"error": "Errore",
"warning": "Avviso",
"info": "Info"
}
},
"errors": {
"generic": "Qualcosa è andato storto. Per favore, riprova.",
"network": "Errore di rete. Per favore, controlla la tua connessione.",
"timeout": "Tempo scaduto. Per favore, riprova.",
"notFound": "L'elemento richiesto non è stato trovato.",
"unauthorized": "Non sei autorizzato a eseguire questa azione.",
"forbidden": "Accesso negato.",
"serverError": "Errore del server. Per favore, riprova più tardi.",
"validation": "Per favore, controlla i tuoi dati e riprova.",
"unknown": "Si è verificato un errore sconosciuto.",
"offline": "Sei offline. Per favore, controlla la tua connessione internet.",
"sessionExpired": "La tua sessione è scaduta. Per favore, accedi di nuovo.",
"rateLimited": "Troppe richieste. Per favore, attendi un momento e riprova."
},
"validation": {
"required": "Questo campo è obbligatorio",
"email": "Per favore, inserisci un indirizzo e-mail valido",
"minLength": "Deve contenere almeno {min} caratteri",
"maxLength": "Deve contenere al massimo {max} caratteri",
"min": "Deve essere almeno {min}",
"max": "Deve essere al massimo {max}",
"pattern": "Formato non valido",
"match": "I campi non corrispondono",
"unique": "Questo valore è già in uso",
"invalid": "Valore non valido",
"url": "Per favore, inserisci un URL valido",
"phone": "Per favore, inserisci un numero di telefono valido",
"number": "Per favore, inserisci un numero valido",
"integer": "Per favore, inserisci un numero intero",
"positive": "Deve essere un numero positivo",
"date": "Per favore, inserisci una data valida",
"futureDate": "La data deve essere nel futuro",
"pastDate": "La data deve essere nel passato",
"password": {
"minLength": "La password deve contenere almeno {min} caratteri",
"uppercase": "La password deve contenere una lettera maiuscola",
"lowercase": "La password deve contenere una lettera minuscola",
"number": "La password deve contenere un numero",
"special": "La password deve contenere un carattere speciale",
"weak": "La password è troppo debole"
}
},
"auth": {
"signIn": "Accedi",
"signOut": "Esci",
"signUp": "Registrati",
"forgotPassword": "Password dimenticata?",
"resetPassword": "Reimposta password",
"changePassword": "Cambia password",
"email": "E-mail",
"password": "Password",
"confirmPassword": "Conferma password",
"rememberMe": "Ricordami",
"orContinueWith": "Oppure continua con",
"alreadyHaveAccount": "Hai già un account?",
"dontHaveAccount": "Non hai un account?",
"errors": {
"invalidCredentials": "E-mail o password non validi",
"emailInUse": "Questa e-mail è già in uso",
"weakPassword": "La password è troppo debole",
"userNotFound": "Utente non trovato",
"tooManyAttempts": "Troppi tentativi. Per favore, riprova più tardi."
}
},
"settings": {
"title": "Impostazioni",
"account": "Account",
"profile": "Profilo",
"preferences": "Preferenze",
"notifications": "Notifiche",
"privacy": "Privacy",
"security": "Sicurezza",
"language": "Lingua",
"theme": "Tema",
"appearance": "Aspetto",
"darkMode": "Modalità scura",
"lightMode": "Modalità chiara",
"systemDefault": "Predefinito di sistema",
"about": "Informazioni",
"help": "Aiuto",
"feedback": "Feedback",
"terms": "Termini di servizio",
"privacyPolicy": "Informativa sulla privacy",
"version": "Versione"
}
}

View file

@ -40,6 +40,24 @@ Handled directly by Better Auth — includes sign-in, sign-up, session, 2FA, mag
### OIDC (`/.well-known/*`, `/api/auth/oauth2/*`)
OpenID Connect provider for Matrix/Synapse SSO.
### Me — GDPR Self-Service (`/api/v1/me/*`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/data` | Full user data summary (auth, credits, project entities) |
| GET | `/data/export` | Download all data as JSON file |
| DELETE | `/data` | Delete all user data across all services (right to be forgotten) |
Aggregates data from 3 sources: auth DB (sessions, accounts, 2FA, passkeys), mana-credits (balance, transactions), mana-sync DB (entity counts per app).
### Admin (`/api/v1/admin/*`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/users` | Paginated user list with search (`?page=1&limit=20&search=`) |
| GET | `/users/:id/data` | Aggregated user data summary (same as /me/data) |
| DELETE | `/users/:id/data` | Delete all user data (admin) |
| GET | `/users/:id/tier` | Get user's access tier |
| PUT | `/users/:id/tier` | Update user's access tier |
### Internal (`/api/v1/internal/*`)
| Method | Path | Description |
|--------|------|-------------|
@ -54,11 +72,13 @@ Session cookies shared across `*.mana.how` via `COOKIE_DOMAIN=.mana.how`.
```env
PORT=3001
DATABASE_URL=postgresql://...
SYNC_DATABASE_URL=postgresql://.../mana_sync # mana-sync DB for entity counts (GDPR data view)
BASE_URL=https://auth.mana.how
COOKIE_DOMAIN=.mana.how
NODE_ENV=production
MANA_CORE_SERVICE_KEY=...
MANA_CREDITS_URL=http://mana-credits:3061
MANA_SUBSCRIPTIONS_URL=http://mana-subscriptions:3063
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=...

View file

@ -1,6 +1,7 @@
export interface Config {
port: number;
databaseUrl: string;
syncDatabaseUrl: string;
baseUrl: string;
cookieDomain: string;
nodeEnv: string;
@ -22,6 +23,10 @@ export function loadConfig(): Config {
return {
port: parseInt(env('PORT', '3001'), 10),
databaseUrl: env('DATABASE_URL', 'postgresql://manacore:devpassword@localhost:5432/mana_auth'),
syncDatabaseUrl: env(
'SYNC_DATABASE_URL',
'postgresql://manacore:devpassword@localhost:5432/mana_sync'
),
baseUrl: env('BASE_URL', 'http://localhost:3001'),
cookieDomain: env('COOKIE_DOMAIN'),
nodeEnv: env('NODE_ENV', 'development'),

View file

@ -17,6 +17,7 @@ import { initializeEmail } from './email/send';
import { SecurityEventsService, AccountLockoutService } from './services/security';
import { SignupLimitService } from './services/signup-limit';
import { ApiKeysService } from './services/api-keys';
import { UserDataService } from './services/user-data';
import { createAuthRoutes } from './routes/auth';
import { createGuildRoutes } from './routes/guilds';
import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys';
@ -35,6 +36,7 @@ const security = new SecurityEventsService(db);
const lockout = new AccountLockoutService(db);
const signupLimit = new SignupLimitService(db);
const apiKeysService = new ApiKeysService(db);
const userDataService = new UserDataService(db, config);
// ─── App ────────────────────────────────────────────────────
@ -80,12 +82,12 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService));
// ─── Me (GDPR) ──────────────────────────────────────────────
app.use('/api/v1/me/*', jwtAuth(config.baseUrl));
app.route('/api/v1/me', createMeRoutes(db));
app.route('/api/v1/me', createMeRoutes(userDataService));
// ─── Admin ──────────────────────────────────────────────────
app.use('/api/v1/admin/*', jwtAuth(config.baseUrl));
app.route('/api/v1/admin', createAdminRoutes(db));
app.route('/api/v1/admin', createAdminRoutes(db, userDataService));
// ─── Internal API ───────────────────────────────────────────

View file

@ -1,8 +1,7 @@
/**
* Admin routes User tier management
* Admin routes User management, tier management, user data access
*
* Protected by JWT auth + admin role check.
* Only users with role 'admin' can manage tiers.
*/
import { Hono } from 'hono';
@ -10,11 +9,12 @@ import { eq } from 'drizzle-orm';
import type { AuthUser } from '../middleware/jwt-auth';
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { users } from '../db/schema/auth';
import type { UserDataService } from '../services/user-data';
const VALID_TIERS = ['guest', 'public', 'beta', 'alpha', 'founder'] as const;
type AccessTier = (typeof VALID_TIERS)[number];
export function createAdminRoutes(db: PostgresJsDatabase<any>) {
export function createAdminRoutes(db: PostgresJsDatabase<any>, userDataService: UserDataService) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
// Admin role check middleware
@ -26,7 +26,74 @@ export function createAdminRoutes(db: PostgresJsDatabase<any>) {
await next();
});
// ─── Update user's access tier ─────────────────────────────
// ─── List users with pagination and search ────────────────
app.get('/users', async (c) => {
const page = parseInt(c.req.query('page') || '1', 10);
const limit = parseInt(c.req.query('limit') || '20', 10);
const search = c.req.query('search');
const tier = c.req.query('tier');
// If tier-only query (legacy), use simple response
if (tier && !search && !c.req.query('page')) {
if (!VALID_TIERS.includes(tier as AccessTier)) {
return c.json({ error: 'Invalid tier' }, 400);
}
const result = await db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
accessTier: users.accessTier,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.accessTier, tier as AccessTier))
.limit(limit);
return c.json({ users: result, count: result.length });
}
// Full paginated list with search
const result = await userDataService.listUsers(page, limit, search || undefined);
return c.json(result);
});
// ─── Get user data summary (aggregated) ───────────────────
app.get('/users/:userId/data', async (c) => {
const { userId } = c.req.param();
const summary = await userDataService.getUserDataSummary(userId);
if (!summary) {
return c.json({ error: 'Not found', message: 'User not found' }, 404);
}
return c.json(summary);
});
// ─── Delete user data ─────────────────────────────────────
app.delete('/users/:userId/data', async (c) => {
const { userId } = c.req.param();
// Get user email first for confirmation
const [user] = await db
.select({ email: users.email })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
return c.json({ error: 'Not found', message: 'User not found' }, 404);
}
const result = await userDataService.deleteUserData(userId, user.email);
return c.json(result);
});
// ─── Update user's access tier ────────────────────────────
app.put('/users/:userId/tier', async (c) => {
const { userId } = c.req.param();
@ -53,13 +120,10 @@ export function createAdminRoutes(db: PostgresJsDatabase<any>) {
return c.json({ error: 'Not found', message: 'User not found' }, 404);
}
return c.json({
success: true,
user: updated,
});
return c.json({ success: true, user: updated });
});
// ─── Get user's current tier ──────────────────────────────
// ─── Get user's current tier ──────────────────────────────
app.get('/users/:userId/tier', async (c) => {
const { userId } = c.req.param();
@ -77,32 +141,5 @@ export function createAdminRoutes(db: PostgresJsDatabase<any>) {
return c.json(user);
});
// ─── List all users with their tiers ───────────────────────
app.get('/users', async (c) => {
const tier = c.req.query('tier');
const limit = parseInt(c.req.query('limit') || '50', 10);
const offset = parseInt(c.req.query('offset') || '0', 10);
let query = db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
accessTier: users.accessTier,
createdAt: users.createdAt,
})
.from(users);
if (tier && VALID_TIERS.includes(tier as AccessTier)) {
query = query.where(eq(users.accessTier, tier as AccessTier)) as typeof query;
}
const result = await query.limit(limit).offset(offset);
return c.json({ users: result, count: result.length });
});
return app;
}

View file

@ -1,49 +1,61 @@
/**
* Me routes GDPR self-service data management
*
* GET /data Full user data summary (auth, credits, projects)
* GET /data/export Download all data as JSON
* DELETE /data Delete all user data (right to be forgotten)
*/
import { Hono } from 'hono';
import { sql } from 'drizzle-orm';
import type { AuthUser } from '../middleware/jwt-auth';
import type { Database } from '../db/connection';
import type { UserDataService } from '../services/user-data';
import { sendAccountDeletionEmail } from '../email/send';
export function createMeRoutes(db: Database) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/data', async (c) => {
const user = c.get('user');
// Return basic user data summary
const result = await db.execute(
sql`SELECT id, email, name, role, created_at FROM auth.users WHERE id = ${user.userId}`
);
return c.json({ user: (result as any)[0] || null });
})
.get('/data/export', async (c) => {
const user = c.get('user');
const [userData] = (await db.execute(
sql`SELECT * FROM auth.users WHERE id = ${user.userId}`
)) as any[];
const sessions = await db.execute(
sql`SELECT id, created_at, expires_at, ip_address FROM auth.sessions WHERE user_id = ${user.userId}`
);
const securityEvents = await db.execute(
sql`SELECT event_type, ip_address, created_at FROM auth.security_events WHERE user_id = ${user.userId} ORDER BY created_at DESC LIMIT 100`
);
export function createMeRoutes(userDataService: UserDataService) {
return (
new Hono<{ Variables: { user: AuthUser } }>()
return c.json({
exportedAt: new Date().toISOString(),
exportVersion: '1.0',
user: userData,
sessions,
securityEvents,
});
})
.delete('/data', async (c) => {
const user = c.get('user');
// Delete user (cascades via FK)
await db.execute(sql`DELETE FROM auth.users WHERE id = ${user.userId}`);
// Send confirmation email
sendAccountDeletionEmail(user.email).catch(() => {});
return c.json({ success: true, message: 'Account and all data deleted' });
});
// ─── Get full user data summary ─────────────────────────
.get('/data', async (c) => {
const user = c.get('user');
const summary = await userDataService.getUserDataSummary(user.userId);
if (!summary) {
return c.json({ error: 'User not found' }, 404);
}
return c.json(summary);
})
// ─── Export user data as JSON download ──────────────────
.get('/data/export', async (c) => {
const user = c.get('user');
const exportData = await userDataService.exportUserData(user.userId);
if (!exportData) {
return c.json({ error: 'User not found' }, 404);
}
const filename = `meine-daten-${new Date().toISOString().split('T')[0]}.json`;
const json = JSON.stringify(exportData, null, 2);
return new Response(json, {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
})
// ─── Delete all user data ───────────────────────────────
.delete('/data', async (c) => {
const user = c.get('user');
const result = await userDataService.deleteUserData(user.userId, user.email);
// Send confirmation email (fire-and-forget)
sendAccountDeletionEmail(user.email).catch(() => {});
return c.json(result);
})
);
}

View file

@ -0,0 +1,579 @@
/**
* User Data Aggregation Service
*
* Aggregates user data from auth DB, mana-credits, and mana-sync
* for GDPR self-service (/me) and admin endpoints.
*/
import { eq, sql, and, count, isNull, desc, ilike, or } from 'drizzle-orm';
import type { Database } from '../db/connection';
import type { Config } from '../config';
import {
users,
sessions,
accounts,
twoFactorAuth,
passkeys,
securityEvents,
} from '../db/schema/auth';
import { apiKeys } from '../db/schema/api-keys';
import postgres from 'postgres';
// ─── Types ─────────────────────────────────────────────────
export interface UserInfo {
id: string;
email: string;
name: string;
role: string;
createdAt: string;
emailVerified: boolean;
}
export interface AuthDataSummary {
sessionsCount: number;
accountsCount: number;
has2FA: boolean;
lastLoginAt: string | null;
}
export interface CreditsDataSummary {
balance: number;
totalEarned: number;
totalSpent: number;
transactionsCount: number;
}
export interface EntityCount {
entity: string;
count: number;
label: string;
}
export interface ProjectDataSummary {
projectId: string;
projectName: string;
icon: string;
available: boolean;
error?: string;
entities: EntityCount[];
totalCount: number;
lastActivityAt?: string;
}
export interface UserDataSummary {
user: UserInfo;
auth: AuthDataSummary;
credits: CreditsDataSummary;
projects: ProjectDataSummary[];
totals: {
totalEntities: number;
projectsWithData: number;
};
}
export interface ProjectDeleteResult {
projectId: string;
projectName: string;
success: boolean;
deletedCount?: number;
error?: string;
}
export interface DeleteUserDataResponse {
success: boolean;
deletedFromProjects: ProjectDeleteResult[];
deletedFromAuth: {
sessions: number;
accounts: number;
credits: number;
user: boolean;
};
totalDeleted: number;
}
export interface UserListItem {
id: string;
email: string;
name: string;
role: string;
createdAt: string;
lastActiveAt?: string;
}
export interface UserListResponse {
users: UserListItem[];
total: number;
page: number;
limit: number;
}
// ─── Project Metadata ──────────────────────────────────────
const PROJECT_META: Record<string, { name: string; icon: string }> = {
todo: { name: 'Todo', icon: '✅' },
chat: { name: 'ManaChat', icon: '💬' },
calendar: { name: 'Kalender', icon: '📅' },
clock: { name: 'Clock', icon: '⏰' },
contacts: { name: 'Kontakte', icon: '👤' },
cards: { name: 'Cards', icon: '🃏' },
picture: { name: 'ManaPicture', icon: '🎨' },
zitare: { name: 'Zitare', icon: '✨' },
presi: { name: 'Presi', icon: '📊' },
inventar: { name: 'Inventar', icon: '📦' },
nutriphi: { name: 'Nutriphi', icon: '🥗' },
planta: { name: 'Planta', icon: '🌱' },
storage: { name: 'Storage', icon: '☁️' },
questions: { name: 'Questions', icon: '❓' },
mukke: { name: 'Mukke', icon: '🎵' },
context: { name: 'Context', icon: '📄' },
photos: { name: 'Photos', icon: '📷' },
skilltree: { name: 'SkillTree', icon: '🌳' },
citycorners: { name: 'CityCorners', icon: '🏙️' },
times: { name: 'Taktik', icon: '⏱️' },
uload: { name: 'uLoad', icon: '🔗' },
calc: { name: 'Calc', icon: '🧮' },
manacore: { name: 'ManaCore', icon: '💎' },
};
/** Convert camelCase/snake_case table name to readable label */
function tableNameToLabel(name: string): string {
return name
.replace(/([A-Z])/g, ' $1')
.replace(/_/g, ' ')
.replace(/^\w/, (c) => c.toUpperCase())
.trim();
}
// ─── Service ───────────────────────────────────────────────
export class UserDataService {
private syncSql: ReturnType<typeof postgres> | null = null;
constructor(
private db: Database,
private config: Config
) {}
private getSyncSql() {
if (!this.syncSql) {
this.syncSql = postgres(this.config.syncDatabaseUrl, { max: 5 });
}
return this.syncSql;
}
// ─── User Info ───────────────────────────────────────────
async getUserInfo(userId: string): Promise<UserInfo | null> {
const [user] = await this.db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt,
emailVerified: users.emailVerified,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) return null;
return {
...user,
createdAt: user.createdAt.toISOString(),
};
}
// ─── Auth Data ───────────────────────────────────────────
async getAuthData(userId: string): Promise<AuthDataSummary> {
const [sessionsResult, accountsResult, twoFaResult, lastSession] = await Promise.all([
this.db
.select({ count: count() })
.from(sessions)
.where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt))),
this.db.select({ count: count() }).from(accounts).where(eq(accounts.userId, userId)),
this.db
.select({ enabled: twoFactorAuth.enabled })
.from(twoFactorAuth)
.where(eq(twoFactorAuth.userId, userId))
.limit(1),
this.db
.select({ lastActivity: sessions.lastActivityAt })
.from(sessions)
.where(eq(sessions.userId, userId))
.orderBy(desc(sessions.lastActivityAt))
.limit(1),
]);
return {
sessionsCount: sessionsResult[0]?.count ?? 0,
accountsCount: accountsResult[0]?.count ?? 0,
has2FA: twoFaResult[0]?.enabled ?? false,
lastLoginAt: lastSession[0]?.lastActivity?.toISOString() ?? null,
};
}
// ─── Credits Data ────────────────────────────────────────
async getCreditsData(userId: string): Promise<CreditsDataSummary> {
try {
const res = await fetch(
`${this.config.manaCreditsUrl}/api/v1/internal/credits/balance/${userId}`,
{ headers: { 'X-Service-Key': this.config.serviceKey } }
);
if (!res.ok) {
return { balance: 0, totalEarned: 0, totalSpent: 0, transactionsCount: 0 };
}
const data = (await res.json()) as {
balance?: number;
totalEarned?: number;
totalSpent?: number;
transactionsCount?: number;
};
return {
balance: data.balance ?? 0,
totalEarned: data.totalEarned ?? 0,
totalSpent: data.totalSpent ?? 0,
transactionsCount: data.transactionsCount ?? 0,
};
} catch {
return { balance: 0, totalEarned: 0, totalSpent: 0, transactionsCount: 0 };
}
}
// ─── Project Data (from mana-sync) ───────────────────────
async getProjectData(userId: string): Promise<ProjectDataSummary[]> {
try {
const syncSql = this.getSyncSql();
// Get entity counts per app/table (latest state, excluding deleted)
const entityCounts = await syncSql`
SELECT app_id, table_name, COUNT(*) as count
FROM (
SELECT DISTINCT ON (app_id, table_name, record_id)
app_id, table_name, record_id, op
FROM sync_changes
WHERE user_id = ${userId}
ORDER BY app_id, table_name, record_id, created_at DESC
) latest
WHERE op != 'delete'
GROUP BY app_id, table_name
ORDER BY app_id, table_name
`;
// Get last activity per app
const lastActivity = await syncSql`
SELECT app_id, MAX(created_at) as last_activity
FROM sync_changes
WHERE user_id = ${userId}
GROUP BY app_id
`;
const lastActivityMap = new Map<string, string>();
for (const row of lastActivity) {
lastActivityMap.set(row.app_id, new Date(row.last_activity).toISOString());
}
// Group by app
const appEntities = new Map<string, EntityCount[]>();
for (const row of entityCounts) {
const appId = row.app_id;
if (!appEntities.has(appId)) {
appEntities.set(appId, []);
}
appEntities.get(appId)!.push({
entity: row.table_name,
count: Number(row.count),
label: tableNameToLabel(row.table_name),
});
}
// Build project summaries for all known projects
const projects: ProjectDataSummary[] = [];
for (const [projectId, meta] of Object.entries(PROJECT_META)) {
const entities = appEntities.get(projectId) || [];
const totalCount = entities.reduce((sum, e) => sum + e.count, 0);
projects.push({
projectId,
projectName: meta.name,
icon: meta.icon,
available: true,
entities,
totalCount,
lastActivityAt: lastActivityMap.get(projectId),
});
}
// Add any unknown apps from sync data
for (const [appId, entities] of appEntities) {
if (!PROJECT_META[appId]) {
const totalCount = entities.reduce((sum, e) => sum + e.count, 0);
projects.push({
projectId: appId,
projectName: appId,
icon: '📁',
available: true,
entities,
totalCount,
lastActivityAt: lastActivityMap.get(appId),
});
}
}
return projects;
} catch (err) {
// If sync DB is unavailable, return all projects as unavailable
return Object.entries(PROJECT_META).map(([projectId, meta]) => ({
projectId,
projectName: meta.name,
icon: meta.icon,
available: false,
error: 'Sync-Datenbank nicht erreichbar',
entities: [],
totalCount: 0,
}));
}
}
// ─── Full Summary ────────────────────────────────────────
async getUserDataSummary(userId: string): Promise<UserDataSummary | null> {
const userInfo = await this.getUserInfo(userId);
if (!userInfo) return null;
const [auth, credits, projects] = await Promise.all([
this.getAuthData(userId),
this.getCreditsData(userId),
this.getProjectData(userId),
]);
const totalEntities = projects.reduce((sum, p) => sum + p.totalCount, 0);
const projectsWithData = projects.filter((p) => p.totalCount > 0).length;
return {
user: userInfo,
auth,
credits,
projects,
totals: { totalEntities, projectsWithData },
};
}
// ─── Export ──────────────────────────────────────────────
async exportUserData(userId: string) {
const summary = await this.getUserDataSummary(userId);
if (!summary) return null;
// Also fetch detailed auth data for export
const [userSessions, userPasskeys, userApiKeys, userSecurityEvents] = await Promise.all([
this.db
.select({
id: sessions.id,
createdAt: sessions.createdAt,
expiresAt: sessions.expiresAt,
ipAddress: sessions.ipAddress,
deviceName: sessions.deviceName,
lastActivityAt: sessions.lastActivityAt,
revokedAt: sessions.revokedAt,
})
.from(sessions)
.where(eq(sessions.userId, userId)),
this.db
.select({
id: passkeys.id,
friendlyName: passkeys.friendlyName,
deviceType: passkeys.deviceType,
createdAt: passkeys.createdAt,
lastUsedAt: passkeys.lastUsedAt,
})
.from(passkeys)
.where(eq(passkeys.userId, userId)),
this.db
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
scopes: apiKeys.scopes,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
revokedAt: apiKeys.revokedAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId)),
this.db
.select({
eventType: securityEvents.eventType,
ipAddress: securityEvents.ipAddress,
createdAt: securityEvents.createdAt,
})
.from(securityEvents)
.where(eq(securityEvents.userId, userId))
.orderBy(desc(securityEvents.createdAt))
.limit(200),
]);
return {
exportedAt: new Date().toISOString(),
exportVersion: '2.0',
data: summary,
details: {
sessions: userSessions,
passkeys: userPasskeys,
apiKeys: userApiKeys,
securityEvents: userSecurityEvents,
},
};
}
// ─── Delete ──────────────────────────────────────────────
async deleteUserData(userId: string, userEmail: string): Promise<DeleteUserDataResponse> {
const deletedFromProjects: ProjectDeleteResult[] = [];
let totalDeleted = 0;
// 1. Delete sync data
try {
const syncSql = this.getSyncSql();
const result = await syncSql`
DELETE FROM sync_changes WHERE user_id = ${userId}
`;
const deletedCount = result.count;
totalDeleted += deletedCount;
deletedFromProjects.push({
projectId: 'sync',
projectName: 'Sync-Daten',
success: true,
deletedCount,
});
} catch (err) {
deletedFromProjects.push({
projectId: 'sync',
projectName: 'Sync-Daten',
success: false,
error: 'Sync-Datenbank nicht erreichbar',
});
}
// 2. Delete credits data
let creditsDeleted = 0;
try {
const res = await fetch(
`${this.config.manaCreditsUrl}/api/v1/internal/credits/balance/${userId}`,
{
method: 'DELETE',
headers: { 'X-Service-Key': this.config.serviceKey },
}
);
if (res.ok) {
const data = (await res.json()) as { deletedCount?: number };
creditsDeleted = data.deletedCount ?? 0;
}
} catch {
// Credits deletion is best-effort
}
// 3. Count auth records before deletion
const [sessionsCount, accountsCount] = await Promise.all([
this.db.select({ count: count() }).from(sessions).where(eq(sessions.userId, userId)),
this.db.select({ count: count() }).from(accounts).where(eq(accounts.userId, userId)),
]);
const deletedSessions = sessionsCount[0]?.count ?? 0;
const deletedAccounts = accountsCount[0]?.count ?? 0;
totalDeleted += deletedSessions + deletedAccounts + creditsDeleted;
// 4. Delete user (cascades sessions, accounts, passkeys, api keys, etc.)
await this.db.delete(users).where(eq(users.id, userId));
totalDeleted += 1; // the user record itself
return {
success: true,
deletedFromProjects,
deletedFromAuth: {
sessions: deletedSessions,
accounts: deletedAccounts,
credits: creditsDeleted,
user: true,
},
totalDeleted,
};
}
// ─── User List (Admin) ───────────────────────────────────
async listUsers(
page: number = 1,
limit: number = 20,
search?: string
): Promise<UserListResponse> {
const offset = (page - 1) * limit;
// Count total
let totalQuery = this.db.select({ count: count() }).from(users);
if (search) {
totalQuery = totalQuery.where(
or(ilike(users.email, `%${search}%`), ilike(users.name, `%${search}%`))
) as typeof totalQuery;
}
const [{ count: total }] = await totalQuery;
// Fetch page with last activity
let query = this.db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt,
})
.from(users);
if (search) {
query = query.where(
or(ilike(users.email, `%${search}%`), ilike(users.name, `%${search}%`))
) as typeof query;
}
const rows = await query.orderBy(desc(users.createdAt)).limit(limit).offset(offset);
// Get last activity for these users
const userIds = rows.map((r) => r.id);
const lastActivities =
userIds.length > 0
? await this.db
.select({
userId: sessions.userId,
lastActivity: sql<Date>`MAX(${sessions.lastActivityAt})`.as('last_activity'),
})
.from(sessions)
.where(sql`${sessions.userId} IN ${userIds}`)
.groupBy(sessions.userId)
: [];
const activityMap = new Map(lastActivities.map((a) => [a.userId, a.lastActivity]));
return {
users: rows.map((r) => ({
id: r.id,
email: r.email,
name: r.name,
role: r.role,
createdAt: r.createdAt.toISOString(),
lastActiveAt: activityMap.get(r.id)?.toISOString(),
})),
total,
page,
limit,
};
}
}