mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:01:09 +02:00
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:
parent
5c66492279
commit
cb85fba820
30 changed files with 2145 additions and 248 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
172
packages/shared-i18n/src/translations/common/es.json
Normal file
172
packages/shared-i18n/src/translations/common/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
172
packages/shared-i18n/src/translations/common/fr.json
Normal file
172
packages/shared-i18n/src/translations/common/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
172
packages/shared-i18n/src/translations/common/it.json
Normal file
172
packages/shared-i18n/src/translations/common/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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=...
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
579
services/mana-auth/src/services/user-data.ts
Normal file
579
services/mana-auth/src/services/user-data.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue