i18n: fix IT/FR/ES parity gaps in dashboard + memoro

- dashboard: +5 Einträge pro Sprache für die beiden neuen Widgets
  activity_feed + articles_unread.
- memoro: +1 Eintrag pro Sprache für memo.load_more.

Damit sind dashboard (111) und memoro auf gleichem Stand wie DE/EN.
Verbleibende Drift (app_slider-Legacy-Keys in memoro IT/FR/ES,
common/auth-Legacy in calendar/times) ist strukturell und bleibt
einem Folge-Cleanup vorbehalten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:19:59 +02:00
parent d49ad239d9
commit 87b567eec9
11 changed files with 671 additions and 1 deletions

View file

@ -153,6 +153,15 @@
"body_stats": {
"title": "Body",
"description": "Peso actual y estado del entrenamiento"
},
"activity_feed": {
"title": "Actividad",
"description": "Cambios recientes en todos los módulos",
"empty": "Aún no hay actividad"
},
"articles_unread": {
"title": "Artículos",
"description": "Artículos no leídos de tu lista de lectura"
}
}
}

View file

@ -153,6 +153,15 @@
"body_stats": {
"title": "Body",
"description": "Poids actuel et statut de l'entraînement"
},
"activity_feed": {
"title": "Activité",
"description": "Modifications récentes sur tous les modules",
"empty": "Aucune activité pour l'instant"
},
"articles_unread": {
"title": "Articles",
"description": "Articles non lus de ta liste de lecture"
}
}
}

View file

@ -153,6 +153,15 @@
"body_stats": {
"title": "Body",
"description": "Peso attuale e stato dell'allenamento"
},
"activity_feed": {
"title": "Attività",
"description": "Modifiche recenti in tutti i moduli",
"empty": "Ancora nessuna attività"
},
"articles_unread": {
"title": "Articoli",
"description": "Articoli non letti dalla tua lista di lettura"
}
}
}

View file

@ -162,6 +162,7 @@
"show_all_memos": "Mostrar todos los memos",
"no_memos_yet": "Aún no hay memos",
"no_memos_hint": "Ve a la página de grabación para crear tu primer memo",
"load_more": "Cargar más memos",
"search_placeholder": "Buscar memos...",
"delete_memo_title": "Eliminar memo",
"delete_memo_confirm": "¿Realmente desea eliminar \"{title}\"?",

View file

@ -162,6 +162,7 @@
"show_all_memos": "Afficher tous les mémos",
"no_memos_yet": "Pas encore de mémos",
"no_memos_hint": "Allez à la page d'enregistrement pour créer votre premier mémo",
"load_more": "Charger plus de mémos",
"search_placeholder": "Rechercher des mémos...",
"delete_memo_title": "Supprimer le mémo",
"delete_memo_confirm": "Voulez-vous vraiment supprimer \"{title}\" ?",

View file

@ -162,6 +162,7 @@
"show_all_memos": "Mostra tutti i memo",
"no_memos_yet": "Ancora nessun memo",
"no_memos_hint": "Vai alla pagina di registrazione per creare il tuo primo memo",
"load_more": "Carica altri memo",
"search_placeholder": "Cerca memo...",
"delete_memo_title": "Elimina memo",
"delete_memo_confirm": "Vuoi davvero eliminare \"{title}\"?",

View file

@ -29,6 +29,7 @@ import type { LocalGoal } from '$lib/companion/goals/types';
import type { LocalPlace } from '$lib/modules/places/types';
import type { LocalRecipe } from '$lib/modules/recipes/types';
import type { LocalWardrobeOutfit } from '$lib/modules/wardrobe/types';
import type { LocalComicStory } from '$lib/modules/comic/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
export interface ResolvedEmbed {
@ -67,6 +68,9 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
case 'wardrobe.outfits':
items = await resolveWardrobeOutfits(props);
break;
case 'comic.stories':
items = await resolveComicStories(props);
break;
default:
return {
items: [],
@ -503,3 +507,63 @@ async function resolveWardrobeOutfits(props: ModuleEmbedProps): Promise<EmbedIte
};
});
}
/**
* Comic-stories: public-comic-portfolio use case. Returns stories
* flipped to 'public' with their cover panel as the card image
* (panelImageIds[0] picture.images.publicUrl). Hard-gated on
* canEmbedOnWebsite.
*
* Whitelist (plan §2): title + "N Panels" subtitle + cover-panel URL.
* Character references, panel captions/dialogues, storyContext, and
* the full panelMeta stay out of the snapshot the cover image is
* already an AI-rendered artifact, the other fields would leak the
* author's briefing and source-entry linkage.
*/
async function resolveComicStories(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let stories = await db.table<LocalComicStory>('comicStories').toArray();
stories = stories.filter(
(s) => !s.deletedAt && !s.isArchived && canEmbedOnWebsite(s.visibility ?? 'private')
);
if (props.filter?.isFavorite === true) {
stories = stories.filter((s) => s.isFavorite === true);
}
if (props.filter?.kind) {
// `kind` reuses the generic filter slot as a style filter so the
// website editor can restrict to e.g. only manga-style comics.
stories = stories.filter((s) => s.style === props.filter?.kind);
}
if (props.filter?.tagIds?.length) {
const wanted = new Set(props.filter.tagIds);
stories = stories.filter((s) => (s.tags ?? []).some((t) => wanted.has(t)));
}
const decrypted = (await decryptRecords('comicStories', stories)) as LocalComicStory[];
// Favourites first, then newest.
decrypted.sort((a, b) => {
const favA = a.isFavorite ? 0 : 1;
const favB = b.isFavorite ? 0 : 1;
if (favA !== favB) return favA - favB;
return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
});
const coverImageIds = decrypted
.map((s) => s.panelImageIds?.[0])
.filter((id): id is string => Boolean(id));
const coverImages = await db.table<LocalImage>('images').where('id').anyOf(coverImageIds).toArray();
const coverById = new Map<string, LocalImage>();
for (const img of coverImages) coverById.set(img.id, img);
return decrypted.map((s) => {
const coverId = s.panelImageIds?.[0];
const cover = coverId ? coverById.get(coverId) : undefined;
const panelCount = s.panelImageIds?.length ?? 0;
return {
title: s.title,
subtitle: `${panelCount} ${panelCount === 1 ? 'Panel' : 'Panels'}`,
imageUrl: cover?.publicUrl ?? undefined,
};
});
}