chore(mana): citycorners + food + wardrobe aus unified-App entfernen
Citycorners-Reste vom vorherigen Sprint mit committet. food → Nutriphi,
wardrobe → Werdrobe sind als Standalone-Apps live; die mana.how-unified-
App trägt die Modul-Surfaces nicht mehr.
Gelöscht / abgebaut:
- Module: apps/mana/.../modules/{food,wardrobe} + Routen + Locales
- Landing-Apps: apps/{food,citycorners}/ Top-Level
- Backend: apps/api/src/modules/{food,wardrobe} + MCP-Tools log_meal /
nutrition_summary, picture-routes verifyMediaOwnership-Allowlist
- shared-branding: APP_BRANDING, APP_ICONS, MANA_APPS, Logos, Onboarding
- shared-ai, mana-tool-registry, credits, shared-types/spaces,
shared-utils/analytics, spiral-db/MANA_APP_INDEX, website-blocks
- Cross-Module: Body-CalorieWeightChart, Comic-CharacterPicker-Wardrobe,
website-Embed wardrobe.outfits, DaySnapshot.nutrition, FoodEventType,
MealLogged/Meal*-Streaks/Goals/Companion/Trigger, AI-Agent-Policy,
GoalEditor MealLogged, MyDay/RitualRunner/Rules nutrition-Refs,
Crypto-Registry meals/wardrobeGarments/wardrobeOutfits
- Generic: PlaceCategory 'food' (places + geocoding + Locales),
spaces.ts 'food'/'wardrobe' Modul-IDs
- Infrastruktur: cloudflared, docker-compose CORS, nginx-Landing,
prometheus-Probe, load-tests, package.json dev-Scripts,
generate-env, mac-mini/build-landings, dependabot
Dexie v62 Migration:
- droppt meals, goals, foodFavorites, mealTags, wardrobeGarments,
wardrobeOutfits Tabellen
- entfernt wardrobeOutfitId / wardrobeGarmentId aus images-Index
- Upgrade-Callback strippt die toten FK-Properties aus alten image-Rows
Test/Doku:
- module-registry.test.ts: Snapshot refresht auf aktuellen Stand mit
56 Modulen (vorher 32, statisch eingefroren pre-refactor). Plus
LEGACY_TABLES-Exclusion für nicht-mehr-registrierte Tabellen aus
cards/citycorners/moodlit/rituals/wishes/who.
- streaks.test.ts: MealLogged-Test in TaskCompleted-Test umgebaut
- apps/mana/CLAUDE.md: food-Refs in AI-Tool-Tabelle und
AiProposalInbox-Liste entfernt
- validate-i18n-keys.mjs + validate-no-recursive-turbo.mjs:
existsSync-Guard, damit die Skripte mit gestaged-aber-rm'ten Dateien
klarkommen
mana-web svelte-check 0 errors / 7436 files, betroffene Tests grün
(streaks, dashboard, module-registry), validate:pg-schema,
validate:turbo, validate:i18n-parity grün.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9
.github/dependabot.yml
vendored
|
|
@ -97,15 +97,6 @@ updates:
|
|||
- "docker"
|
||||
- "automated"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/apps/food/apps/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "docker"
|
||||
- "automated"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/apps/news/apps/api"
|
||||
schedule:
|
||||
|
|
|
|||
|
|
@ -31,13 +31,10 @@ import { chatRoutes } from './modules/chat/routes';
|
|||
import { notesRoutes } from './modules/notes/routes';
|
||||
import { pictureRoutes } from './modules/picture/routes';
|
||||
import { profileRoutes } from './modules/profile/routes';
|
||||
import { wardrobeRoutes } from './modules/wardrobe/routes';
|
||||
import { storageRoutes } from './modules/storage/routes';
|
||||
import { todoRoutes } from './modules/todo/routes';
|
||||
import { plantsRoutes } from './modules/plants/routes';
|
||||
import { foodRoutes } from './modules/food/routes';
|
||||
import { guidesRoutes } from './modules/guides/routes';
|
||||
import { moodlitRoutes } from './modules/moodlit/routes';
|
||||
import { newsRoutes } from './modules/news/routes';
|
||||
import { newsResearchRoutes } from './modules/news-research/routes';
|
||||
import { articlesRoutes } from './modules/articles/routes';
|
||||
|
|
@ -100,11 +97,10 @@ app.use('/api/*', authMiddleware());
|
|||
// to `beta`+ so that unauthenticated guest fallbacks (tier='public'
|
||||
// from a missing claim) can't hit paid infrastructure.
|
||||
// Pure CRUD modules (calendar, contacts, music, storage, todo, news,
|
||||
// presi, moodlit) rely on authMiddleware alone — users access only
|
||||
// presi) rely on authMiddleware alone — users access only
|
||||
// their own records.
|
||||
const RESOURCE_MODULES = [
|
||||
'chat',
|
||||
'food',
|
||||
'guides',
|
||||
'kontext',
|
||||
'news-research',
|
||||
|
|
@ -135,13 +131,10 @@ app.route('/api/v1/chat', chatRoutes);
|
|||
app.route('/api/v1/notes', notesRoutes);
|
||||
app.route('/api/v1/picture', pictureRoutes);
|
||||
app.route('/api/v1/profile', profileRoutes);
|
||||
app.route('/api/v1/wardrobe', wardrobeRoutes);
|
||||
app.route('/api/v1/storage', storageRoutes);
|
||||
app.route('/api/v1/todo', todoRoutes);
|
||||
app.route('/api/v1/plants', plantsRoutes);
|
||||
app.route('/api/v1/food', foodRoutes);
|
||||
app.route('/api/v1/guides', guidesRoutes);
|
||||
app.route('/api/v1/moodlit', moodlitRoutes);
|
||||
app.route('/api/v1/news', newsRoutes);
|
||||
app.route('/api/v1/news-research', newsResearchRoutes);
|
||||
app.route('/api/v1/articles', articlesRoutes);
|
||||
|
|
|
|||
|
|
@ -91,11 +91,10 @@ export async function getMediaBufferAsPng(
|
|||
* doesn't land in the owned set — the caller turns that into an HTTP
|
||||
* response.
|
||||
*
|
||||
* Accepts a single app string or an array. The Wardrobe try-on flow
|
||||
* (plan docs/plans/wardrobe-module.md M4) passes `['me', 'wardrobe']`
|
||||
* in one call — face-ref and body-ref live under `me`, garments live
|
||||
* under `wardrobe`, both legitimate inputs for the same `/v1/images/
|
||||
* edits` POST.
|
||||
* Accepts a single app string or an array. Comic character-ref flows
|
||||
* pass `['me', 'comic']` in one call when both face/body portraits and
|
||||
* comic-specific anchors are legitimate inputs for the same
|
||||
* `/v1/images/edits` POST.
|
||||
*
|
||||
* One `list()` round-trip per app. For N apps this is N calls, each
|
||||
* capped at 500 rows — far beyond the product's intended per-app shape
|
||||
|
|
|
|||
|
|
@ -529,44 +529,6 @@ register('undo_drink', async (_args, userId) => {
|
|||
return ok(`Letzter Drink-Eintrag (${last.name}) rückgängig gemacht.`);
|
||||
});
|
||||
|
||||
// ── Food tools ────────────────────────────────────────────────
|
||||
|
||||
register('log_meal', async (args, userId) => {
|
||||
const mealId = crypto.randomUUID();
|
||||
const now = nowIso();
|
||||
const today = now.split('T')[0];
|
||||
const data = {
|
||||
id: mealId,
|
||||
userId,
|
||||
mealType: args.mealType as string,
|
||||
description: args.description as string,
|
||||
calories: (args.calories as number) ?? null,
|
||||
protein: (args.protein as number) ?? null,
|
||||
date: today,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await writeRecord(userId, 'food', 'meals', mealId, 'insert', data, fieldTs(Object.keys(data)));
|
||||
return ok(`${args.mealType}: "${args.description}" geloggt.`, { id: mealId });
|
||||
});
|
||||
|
||||
register('nutrition_summary', async (_args, userId) => {
|
||||
const records = await readLatestRecords(userId, 'food', 'meals');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayMeals = records.filter((r) => r.date === today);
|
||||
let totalCal = 0,
|
||||
totalProtein = 0;
|
||||
for (const m of todayMeals) {
|
||||
totalCal += (m.calories as number) ?? 0;
|
||||
totalProtein += (m.protein as number) ?? 0;
|
||||
}
|
||||
return ok(`Heute: ${todayMeals.length} Mahlzeiten, ${totalCal} kcal, ${totalProtein}g Protein`, {
|
||||
meals: todayMeals.length,
|
||||
calories: totalCal,
|
||||
protein: totalProtein,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Journal tools ─────────────────────────────────────────────
|
||||
|
||||
register('create_journal_entry', async (args, userId) => {
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
/**
|
||||
* Food module — Meal analysis (Gemini Vision via mana-llm) + recommendations.
|
||||
*
|
||||
* CRUD for meals, goals, favorites is handled by mana-sync. This module
|
||||
* owns the server-only operations: photo upload to mana-media, structured
|
||||
* AI analysis using the Vercel AI SDK (`generateObject`) against the
|
||||
* shared Zod schema in @mana/shared-types, and a small rule-based
|
||||
* recommendation engine.
|
||||
*
|
||||
* Why generateObject + Zod instead of raw fetch?
|
||||
* - Runtime validation of the AI response — if Gemini drifts on a
|
||||
* field, we throw at the boundary instead of corrupting downstream
|
||||
* state. The frontend never sees malformed data.
|
||||
* - Provider-portable structured outputs: the AI SDK translates one
|
||||
* Zod schema into OpenAI strict json_schema / Anthropic tool-use /
|
||||
* Gemini response_schema depending on which backend mana-llm routes
|
||||
* to. We don't have to know which.
|
||||
* - Single source of truth: the same MealAnalysisSchema is consumed
|
||||
* by the unified web app via `z.infer<typeof MealAnalysisSchema>`,
|
||||
* so changes here propagate end-to-end without manual sync.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { generateObject } from 'ai';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import {
|
||||
AI_SCHEMA_VERSION,
|
||||
MealAnalysisSchema,
|
||||
type AiResponseEnvelope,
|
||||
type MealAnalysis,
|
||||
} from '@mana/shared-types';
|
||||
import { logger, type AuthVariables } from '@mana/shared-hono';
|
||||
import { MANA_LLM } from '@mana/shared-ai';
|
||||
|
||||
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
|
||||
// mana-llm resolves this alias to a healthy vision model (chain in
|
||||
// services/mana-llm/aliases.yaml). To swap the chain, edit the YAML
|
||||
// and SIGHUP — no service redeploy here.
|
||||
const VISION_MODEL = MANA_LLM.VISION;
|
||||
|
||||
const llm = createOpenAICompatible({
|
||||
name: 'mana-llm',
|
||||
// mana-llm exposes /v1/chat/completions (see services/mana-llm/CLAUDE.md +
|
||||
// src/main.py:125). The AI SDK's openai-compatible adapter appends
|
||||
// /chat/completions to baseURL, so baseURL ends in /v1.
|
||||
baseURL: `${LLM_URL}/v1`,
|
||||
// Tell the AI SDK that mana-llm honours OpenAI-style strict
|
||||
// json_schema response_format. Without this, generateObject() falls
|
||||
// back to a tool-call mode that Ollama-backed models don't support
|
||||
// reliably and the response fails to validate against the Zod schema.
|
||||
// mana-llm's Ollama provider translates response_format → Ollama's
|
||||
// native `format` field (services/mana-llm/src/providers/ollama.py)
|
||||
// so this is honoured end-to-end.
|
||||
supportsStructuredOutputs: true,
|
||||
});
|
||||
|
||||
const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere die Mahlzeit und gib strukturierte Nährwertdaten zurück. Schätze realistische Portionsgrößen und Kalorien. Antworte auf Deutsch.`;
|
||||
|
||||
/**
|
||||
* Provider hints attached to the system message. Forward-compat:
|
||||
*
|
||||
* - anthropic.cacheControl: ephemeral system-prompt caching. NO-OP today
|
||||
* because (a) we route to Gemini via mana-llm and (b) the prompt is
|
||||
* ~50 tokens — well under Anthropic's 1024-token cache minimum. Becomes
|
||||
* active automatically when mana-llm routes to Claude AND the prompt
|
||||
* grows (e.g. once we attach per-user dietary preferences as system
|
||||
* context, which would push us past the threshold).
|
||||
*
|
||||
* Kept here so the day we flip the backend, we don't have to revisit
|
||||
* every route to enable caching — it just starts working.
|
||||
*/
|
||||
const SYSTEM_CACHE_HINT = {
|
||||
anthropic: { cacheControl: { type: 'ephemeral' as const } },
|
||||
};
|
||||
|
||||
/** Wrap a validated AI object in the standard wire-format envelope. */
|
||||
function envelope(data: MealAnalysis): AiResponseEnvelope<MealAnalysis> {
|
||||
return { schemaVersion: AI_SCHEMA_VERSION, data };
|
||||
}
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
// ─── Photo Upload (server-only: S3 storage via mana-media) ───
|
||||
|
||||
routes.post('/photos/upload', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) return c.json({ error: 'No file provided' }, 400);
|
||||
if (file.size > 10 * 1024 * 1024) return c.json({ error: 'File too large (max 10MB)' }, 400);
|
||||
|
||||
try {
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const buffer = await file.arrayBuffer();
|
||||
const result = await uploadImageToMedia(buffer, file.name, { app: 'food', userId });
|
||||
|
||||
return c.json(
|
||||
{
|
||||
mediaId: result.id,
|
||||
publicUrl: result.urls.original,
|
||||
thumbnailUrl: result.urls.thumbnail || result.urls.original,
|
||||
storagePath: result.id,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('food.upload_failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Photo Analysis (Gemini Vision on uploaded URL) ──────────
|
||||
|
||||
routes.post('/analysis/photo', async (c) => {
|
||||
const { photoUrl } = await c.req.json();
|
||||
if (!photoUrl) return c.json({ error: 'photoUrl required' }, 400);
|
||||
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: llm(VISION_MODEL),
|
||||
schema: MealAnalysisSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: ANALYSIS_PROMPT,
|
||||
providerOptions: SYSTEM_CACHE_HINT,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Analysiere diese Mahlzeit.' },
|
||||
{ type: 'image', image: new URL(photoUrl) },
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.3,
|
||||
});
|
||||
return c.json(envelope(object));
|
||||
} catch (err) {
|
||||
logger.error('food.photo_analysis_failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return c.json({ error: 'Analysis failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Text Analysis (Gemini on a free-text meal description) ──
|
||||
|
||||
routes.post('/analysis/text', async (c) => {
|
||||
const { description } = await c.req.json();
|
||||
if (!description) return c.json({ error: 'description required' }, 400);
|
||||
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: llm(VISION_MODEL),
|
||||
schema: MealAnalysisSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: ANALYSIS_PROMPT,
|
||||
providerOptions: SYSTEM_CACHE_HINT,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Analysiere diese Mahlzeit: ${description}`,
|
||||
},
|
||||
],
|
||||
temperature: 0.3,
|
||||
});
|
||||
return c.json(envelope(object));
|
||||
} catch (err) {
|
||||
logger.error('food.text_analysis_failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return c.json({ error: 'Analysis failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Recommendations (server-only: rule engine) ──────────────
|
||||
|
||||
routes.post('/recommendations/generate', async (c) => {
|
||||
const { dailyNutrition } = await c.req.json();
|
||||
const hints: Array<{ type: string; priority: string; message: string; nutrient?: string }> = [];
|
||||
|
||||
if (dailyNutrition) {
|
||||
if (dailyNutrition.protein < 25) {
|
||||
hints.push({
|
||||
type: 'hint',
|
||||
priority: 'medium',
|
||||
message:
|
||||
'Deine Proteinzufuhr ist niedrig. Versuche Hülsenfrüchte, Eier oder Joghurt einzubauen.',
|
||||
nutrient: 'protein',
|
||||
});
|
||||
}
|
||||
if (dailyNutrition.fiber < 10) {
|
||||
hints.push({
|
||||
type: 'hint',
|
||||
priority: 'medium',
|
||||
message: 'Mehr Ballaststoffe! Vollkornprodukte, Gemüse und Obst helfen.',
|
||||
nutrient: 'fiber',
|
||||
});
|
||||
}
|
||||
if (dailyNutrition.sugar > 50) {
|
||||
hints.push({
|
||||
type: 'hint',
|
||||
priority: 'high',
|
||||
message:
|
||||
'Dein Zuckerkonsum ist hoch. Achte auf versteckten Zucker in Getränken und Fertigprodukten.',
|
||||
nutrient: 'sugar',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ recommendations: hints });
|
||||
});
|
||||
|
||||
export { routes as foodRoutes };
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/**
|
||||
* Moodlit module — Preset moods library
|
||||
* Ported from apps/moodlit/apps/server
|
||||
*
|
||||
* Local-first for user moods/sequences.
|
||||
* This module serves the default preset library.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const DEFAULT_MOODS = [
|
||||
{ id: 'fire', name: 'Fire', colors: ['#ff6b35', '#f72585', '#ff006e'], animation: 'flicker' },
|
||||
{ id: 'breath', name: 'Breath', colors: ['#4361ee', '#3a0ca3', '#7209b7'], animation: 'pulse' },
|
||||
{
|
||||
id: 'northern-lights',
|
||||
name: 'Northern Lights',
|
||||
colors: ['#06d6a0', '#118ab2', '#073b4c'],
|
||||
animation: 'aurora',
|
||||
},
|
||||
{ id: 'thunder', name: 'Thunder', colors: ['#14213d', '#fca311', '#e5e5e5'], animation: 'flash' },
|
||||
{
|
||||
id: 'sunset',
|
||||
name: 'Sunset',
|
||||
colors: ['#ff6b6b', '#feca57', '#ff9ff3'],
|
||||
animation: 'gradient',
|
||||
},
|
||||
{ id: 'ocean', name: 'Ocean', colors: ['#0077b6', '#00b4d8', '#90e0ef'], animation: 'wave' },
|
||||
{ id: 'forest', name: 'Forest', colors: ['#2d6a4f', '#40916c', '#52b788'], animation: 'sway' },
|
||||
{
|
||||
id: 'lavender',
|
||||
name: 'Lavender',
|
||||
colors: ['#7b2cbf', '#9d4edd', '#c77dff'],
|
||||
animation: 'pulse',
|
||||
},
|
||||
];
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get('/presets', (c) => c.json(DEFAULT_MOODS));
|
||||
|
||||
export { routes as moodlitRoutes };
|
||||
|
|
@ -254,11 +254,8 @@ routes.post('/generate', async (c) => {
|
|||
// image input natively. Replicate/local fallback is a later milestone.
|
||||
|
||||
// OpenAI gpt-image-1 / gpt-image-2 accept up to 16 reference images per
|
||||
// edit call. We clamp at 8 to cover the Wardrobe try-on workflow — one
|
||||
// face-ref + one body-ref + up to six garment photos (top/bottom/shoes/
|
||||
// outerwear + two accessories) — while keeping credit exposure and
|
||||
// upload payload size predictable. Pre-wardrobe the cap was 4; bumped
|
||||
// in docs/plans/wardrobe-module.md M1.
|
||||
// edit call. We clamp at 8 to keep credit exposure and upload payload
|
||||
// size predictable.
|
||||
const MAX_REFERENCE_IMAGES = 8;
|
||||
|
||||
routes.post('/generate-with-reference', async (c) => {
|
||||
|
|
@ -318,18 +315,14 @@ routes.post('/generate-with-reference', async (c) => {
|
|||
}
|
||||
|
||||
// Ownership check before we spend credits or burn OpenAI quota.
|
||||
// References span three upload tags today:
|
||||
// - `me` — face/body portraits from the profile module
|
||||
// - `wardrobe` — garment photos (M4 try-on flow)
|
||||
// - `comic` — comic-specific anchor / backdrop uploads
|
||||
// (slot reserved for M6+; no writer lands in
|
||||
// this app today, M1 character refs come from
|
||||
// me + wardrobe only).
|
||||
// References span two upload tags today:
|
||||
// - `me` — face/body portraits from the profile module
|
||||
// - `comic` — comic-specific anchor / backdrop uploads
|
||||
// Anything outside these apps is treated as not-owned regardless of
|
||||
// mana-media's own view.
|
||||
try {
|
||||
const { verifyMediaOwnership } = await import('../../lib/media');
|
||||
await verifyMediaOwnership(userId, refIds, ['me', 'wardrobe', 'comic']);
|
||||
await verifyMediaOwnership(userId, refIds, ['me', 'comic']);
|
||||
} catch (err) {
|
||||
const e = err as Error & { status?: number; missing?: string[] };
|
||||
if (e.status === 404) {
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
/**
|
||||
* Wardrobe module — server endpoints.
|
||||
*
|
||||
* Thin wrapper around mana-media for garment photo uploads. Plan:
|
||||
* docs/plans/wardrobe-module.md M1. No logic beyond tagging uploads
|
||||
* as `app='wardrobe'` so a later `GET /api/v1/media?app=wardrobe&...`
|
||||
* query can enumerate a user's garment pool without scanning every
|
||||
* media reference.
|
||||
*
|
||||
* Try-on generation does NOT live here — it reuses the Picture
|
||||
* module's POST /api/v1/picture/generate-with-reference endpoint
|
||||
* with MAX_REFERENCE_IMAGES bumped to 8 so face + body + garments
|
||||
* fit into one call.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthVariables } from '@mana/shared-hono';
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
// Same 10MB cap as the other photo-upload endpoints (profile me-images,
|
||||
// picture uploads). Phone-camera PNG/HEIC routinely comes in under 6MB.
|
||||
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
routes.post('/garments/upload', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) return c.json({ error: 'No file' }, 400);
|
||||
if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: 'Max 10MB' }, 400);
|
||||
|
||||
try {
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const buffer = await file.arrayBuffer();
|
||||
const result = await uploadImageToMedia(buffer, file.name, {
|
||||
app: 'wardrobe',
|
||||
userId,
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
mediaId: result.id,
|
||||
storagePath: result.id,
|
||||
publicUrl: result.urls.original,
|
||||
thumbnailUrl: result.urls.thumbnail,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (_err) {
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { routes as wardrobeRoutes };
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
# CityCorners
|
||||
|
||||
Open platform for city guides worldwide — users create cities and add locations, growing the platform organically from the community.
|
||||
|
||||
## Live URLs
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| **Web App** | https://citycorners.mana.how |
|
||||
| **Landing** | https://citycorners-landing.pages.dev |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/citycorners/
|
||||
├── apps/
|
||||
│ ├── landing/ # Astro static site (Tailwind, Cloudflare Pages)
|
||||
│ └── web/ # SvelteKit web app (port 5196 dev, 5022 prod)
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
- **Data Layer:** Local-first via @mana/local-store (Dexie.js/IndexedDB)
|
||||
- **Sync:** mana-sync (Go, WebSocket) for server synchronization
|
||||
- **Web:** SvelteKit 2, Svelte 5 runes, Tailwind 4, OpenStreetMap embeds, svelte-i18n (DE/EN), PWA
|
||||
- **Landing:** Astro 5, Tailwind 3, static site generation
|
||||
- **Auth:** mana-auth (JWT, guest mode supported)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Full stack (auth + web)
|
||||
pnpm dev:citycorners:full
|
||||
|
||||
# Individual apps
|
||||
pnpm dev:citycorners:landing
|
||||
pnpm dev:citycorners:web
|
||||
```
|
||||
|
||||
## Data Model (Local-First)
|
||||
|
||||
Three IndexedDB collections managed by `@mana/local-store`:
|
||||
|
||||
### Cities
|
||||
- **id** (string, PK)
|
||||
- **name** (string) — city/village/town name
|
||||
- **slug** (string, indexed) — URL-friendly name
|
||||
- **country** (string, indexed)
|
||||
- **state** (string, optional) — state/region
|
||||
- **description** (string, optional)
|
||||
- **latitude** (number) — center coordinates
|
||||
- **longitude** (number)
|
||||
- **imageUrl** (string, optional)
|
||||
- **createdBy** (string, optional) — user ID
|
||||
|
||||
### Locations
|
||||
- **id** (string, PK)
|
||||
- **cityId** (string, indexed, FK → cities)
|
||||
- **name** (string, indexed)
|
||||
- **category** (enum, indexed: sight/restaurant/shop/museum/cafe/bar/park/beach/hotel/event_venue/viewpoint)
|
||||
- **description** (string, optional)
|
||||
- **address** (string, optional)
|
||||
- **latitude/longitude** (number, optional)
|
||||
- **imageUrl** (string, optional)
|
||||
- **timeline** (JSON array of {year, event}, optional)
|
||||
|
||||
### Favorites
|
||||
- **id** (string, PK)
|
||||
- **locationId** (string, indexed, FK → locations)
|
||||
|
||||
## Web App Routes
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/` | City discovery — search & browse cities |
|
||||
| `/add-city` | Create a new city (auth required) |
|
||||
| `/cities/:slug` | City home — location grid with category filters |
|
||||
| `/cities/:slug/map` | OpenStreetMap with location list |
|
||||
| `/cities/:slug/add` | Add a location to city (auth required) |
|
||||
| `/cities/:slug/locations/:id` | Location detail with map, timeline, nearby |
|
||||
| `/cities/:slug/locations/:id/edit` | Edit location (creator only) |
|
||||
| `/favorites` | User's saved locations |
|
||||
| `/settings` | Theme mode/variant, account, about |
|
||||
| `/login`, `/register` | Auth via shared-auth-ui |
|
||||
| `/offline` | PWA offline fallback |
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-City Platform:** Users create cities/villages and add locations within them
|
||||
- **Local-First:** All CRUD via IndexedDB, works offline, syncs to server
|
||||
- **Guest Mode:** Browse with seed data (Konstanz, Zürich, Berlin)
|
||||
- **PWA:** Installable, offline fallback, service worker caching
|
||||
- **i18n:** German + English, language switcher
|
||||
- **Context-Aware Navigation:** Nav items change based on city context
|
||||
- **Categories:** 11 location types with color-coded markers
|
||||
- **Favorites:** Heart button on cards, auth-gated
|
||||
- **Geocoding:** Auto-coordinates from city/address names (Nominatim)
|
||||
- **Slug Generation:** Auto-generated URL-safe slugs with umlaut handling
|
||||
|
||||
## Categories
|
||||
|
||||
| DB Value | Label (DE) | Label (EN) | Marker Color |
|
||||
|----------|------------|------------|------------|
|
||||
| `sight` | Sehenswürdigkeit | Sight | Blue |
|
||||
| `restaurant` | Restaurant | Restaurant | Red |
|
||||
| `shop` | Laden | Shop | Green |
|
||||
| `museum` | Museum | Museum | Purple |
|
||||
| `cafe` | Café | Café | Amber |
|
||||
| `bar` | Bar | Bar | Orange |
|
||||
| `park` | Park | Park | Emerald |
|
||||
| `beach` | Strandbad | Beach | Cyan |
|
||||
| `hotel` | Hotel | Hotel | Indigo |
|
||||
| `event_venue` | Veranstaltungsort | Event Venue | Pink |
|
||||
| `viewpoint` | Aussichtspunkt | Viewpoint | Sky |
|
||||
|
||||
## Docker
|
||||
|
||||
- **Web:** `apps/citycorners/apps/web/Dockerfile` (multi-stage, port 5022 prod)
|
||||
- **docker-compose.macmini.yml:** Web service with health check
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Used by | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PUBLIC_MANA_AUTH_URL` | Web | Auth service URL (client) |
|
||||
| `PUBLIC_SYNC_SERVER_URL` | Web | mana-sync WebSocket URL |
|
||||
26
apps/citycorners/apps/landing/.gitignore
vendored
|
|
@ -1,26 +0,0 @@
|
|||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
astro_dev.log
|
||||
server.log
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
# Astro Starter Kit: Basics
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template basics
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src
|
||||
│ ├── assets
|
||||
│ │ └── astro.svg
|
||||
│ ├── components
|
||||
│ │ └── Welcome.astro
|
||||
│ ├── layouts
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://citycorners.mana.how',
|
||||
integrations: [tailwind(), sitemap()],
|
||||
});
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"name": "@citycorners/landing",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@astrojs/tailwind": "^6.0.0",
|
||||
"astro": "^5.16.11",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 749 B |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 467 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
interface Props {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const { title = 'CityCorners – Entdecke Städte weltweit' } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta
|
||||
name="description"
|
||||
content="CityCorners – Die offene Plattform für Stadtführer. Entdecke Orte weltweit, geteilt von der Community."
|
||||
/>
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-50 text-gray-900 antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
|
||||
const APP_URL = 'https://citycorners.mana.how';
|
||||
|
||||
const exampleCities = [
|
||||
{
|
||||
name: 'Konstanz',
|
||||
country: 'Deutschland',
|
||||
description: 'Universitätsstadt am Bodensee mit mittelalterlicher Altstadt',
|
||||
slug: 'konstanz',
|
||||
},
|
||||
{
|
||||
name: 'Zürich',
|
||||
country: 'Schweiz',
|
||||
description: 'Größte Stadt der Schweiz am Zürichsee',
|
||||
slug: 'zuerich',
|
||||
},
|
||||
{
|
||||
name: 'Berlin',
|
||||
country: 'Deutschland',
|
||||
description: 'Hauptstadt mit vielfältiger Kultur und Geschichte',
|
||||
slug: 'berlin',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<!-- Hero -->
|
||||
<header class="bg-gradient-to-br from-blue-600 to-blue-800 py-20 text-white">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<h1 class="text-4xl font-bold sm:text-5xl lg:text-6xl">CityCorners</h1>
|
||||
<p class="mt-4 text-xl text-blue-100">Entdecke Städte weltweit</p>
|
||||
<p class="mt-2 text-blue-200">
|
||||
Von der Community für die Community — teile deine Lieblingsorte
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
||||
<a
|
||||
href={APP_URL}
|
||||
class="rounded-lg bg-white px-8 py-3 text-lg font-semibold text-blue-700 shadow-lg transition-all hover:bg-blue-50 hover:shadow-xl"
|
||||
>
|
||||
App öffnen
|
||||
</a>
|
||||
<a
|
||||
href={`${APP_URL}/add-city`}
|
||||
class="rounded-lg border-2 border-white/30 px-8 py-3 text-lg font-semibold text-white transition-all hover:border-white/60 hover:bg-white/10"
|
||||
>
|
||||
Stadt hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- How it works -->
|
||||
<section class="mx-auto max-w-5xl px-6 py-16">
|
||||
<h2 class="mb-10 text-center text-3xl font-bold text-gray-900">So funktioniert's</h2>
|
||||
<div class="grid gap-8 sm:grid-cols-3">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-100 text-3xl"
|
||||
>
|
||||
🏙️
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold">Stadt anlegen</h3>
|
||||
<p class="mt-2 text-gray-600">
|
||||
Lege deine Stadt, dein Dorf oder deinen Lieblingsort an — egal wo auf der Welt.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-green-100 text-3xl"
|
||||
>
|
||||
📍
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold">Orte hinzufügen</h3>
|
||||
<p class="mt-2 text-gray-600">
|
||||
Teile Restaurants, Sehenswürdigkeiten, Cafés, Parks und mehr mit der Community.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-100 text-3xl"
|
||||
>
|
||||
🗺️
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold">Entdecken</h3>
|
||||
<p class="mt-2 text-gray-600">
|
||||
Erkunde Städte auf der Karte, filtere nach Kategorien und speichere Favoriten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Example cities -->
|
||||
<section class="bg-white py-16">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="mb-8 text-center text-3xl font-bold text-gray-900">Beispielstädte</h2>
|
||||
<div class="grid gap-6 sm:grid-cols-3">
|
||||
{
|
||||
exampleCities.map((city) => (
|
||||
<a
|
||||
href={`${APP_URL}/cities/${city.slug}`}
|
||||
class="group rounded-xl border border-gray-200 bg-gray-50 p-6 transition-all hover:border-blue-300 hover:shadow-md"
|
||||
>
|
||||
<h3 class="text-xl font-semibold text-gray-900 group-hover:text-blue-600">
|
||||
{city.name}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">{city.country}</p>
|
||||
<p class="mt-2 text-gray-600">{city.description}</p>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="mx-auto max-w-5xl px-6 py-16">
|
||||
<h2 class="mb-8 text-center text-3xl font-bold text-gray-900">Features</h2>
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div class="rounded-xl border border-gray-200 p-5">
|
||||
<h3 class="font-semibold text-gray-900">Offline verfügbar</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Funktioniert auch ohne Internet — Daten werden lokal gespeichert und automatisch
|
||||
synchronisiert.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 p-5">
|
||||
<h3 class="font-semibold text-gray-900">11 Kategorien</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Restaurants, Cafés, Museen, Parks, Hotels, Bars, Sehenswürdigkeiten und mehr.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 p-5">
|
||||
<h3 class="font-semibold text-gray-900">Interaktive Karte</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Farbcodierte Marker auf OpenStreetMap mit Standortbestimmung.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 p-5">
|
||||
<h3 class="font-semibold text-gray-900">Mehrsprachig</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">Deutsch und Englisch mit einfachem Sprachwechsel.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-gray-200 bg-white py-8">
|
||||
<div class="mx-auto max-w-6xl px-6 text-center text-sm text-gray-500">
|
||||
<p>CityCorners – Die offene Plattform für Stadtführer</p>
|
||||
<p class="mt-1">
|
||||
Teil des <a href="https://mana.how" class="text-blue-600 hover:underline">Mana</a>{' '}
|
||||
Ökosystems
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</Layout>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
'primary-dark': '#1d4ed8',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# Cloudflare Pages configuration for CityCorners Landing
|
||||
# Deployed via GitHub Actions (Direct Upload)
|
||||
|
||||
name = "citycorners-landing"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# Food — consolidated into the unified Mana app
|
||||
|
||||
This product was migrated into the unified Mana monorepo. The legacy
|
||||
per-product `apps/food/apps/backend/` and `apps/food/apps/web/`
|
||||
directories have been removed. Active code now lives in:
|
||||
|
||||
- **Backend compute routes**: [`apps/api/src/modules/food/routes.ts`](../api/src/modules/food/routes.ts) (Gemini meal-photo analysis + text analysis + recommendations)
|
||||
- **Frontend module** (local-first): [`apps/mana/apps/web/src/lib/modules/food/`](../mana/apps/web/src/lib/modules/food/)
|
||||
- **Web route**: [`apps/mana/apps/web/src/routes/(app)/food/`](../mana/apps/web/src/routes/(app)/food/)
|
||||
- **Landing page** (still standalone): [`apps/food/apps/landing/`](apps/landing/)
|
||||
|
||||
For monorepo-wide patterns (auth, sync, encryption, services), see the
|
||||
[root `CLAUDE.md`](../../CLAUDE.md) and [`apps/mana/CLAUDE.md`](../mana/CLAUDE.md).
|
||||
|
||||
The previous standalone "Food Project Guide" was deleted in the
|
||||
audit cleanup of 2026-04-09 — it had been inaccurate since the
|
||||
consolidation. Pre-consolidation reference is in git history.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()],
|
||||
site: 'https://food.mana.how',
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"name": "@food/landing",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 4323",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check",
|
||||
"format": "prettier --write .",
|
||||
"clean": "rm -rf dist .astro node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@mana/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.16.0",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@tailwindcss/typography": "^0.5.18",
|
||||
"@types/node": "^20.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-astro": "^1.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^3.4.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
---
|
||||
import Analytics from '@mana/shared-landing-ui/atoms/Analytics.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = 'Food - KI-gestützte Ernährungsanalyse per Foto' } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#22C55E" />
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
{
|
||||
import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
|
||||
<script
|
||||
defer
|
||||
src="https://stats.mana.how/script.js"
|
||||
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</head>
|
||||
<body class="bg-[#0F1F0F] text-gray-100 antialiased">
|
||||
<slot />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: '📸',
|
||||
title: 'Foto-Analyse',
|
||||
description: 'Mach einfach ein Foto von deinem Essen und lass die KI die Nährwerte berechnen.',
|
||||
},
|
||||
{
|
||||
icon: '🥗',
|
||||
title: 'Vollständige Nährwerte',
|
||||
description: 'Kalorien, Makros, Vitamine und Mineralstoffe auf einen Blick.',
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Persönliche Ziele',
|
||||
description: 'Setze deine Tagesziele und verfolge deinen Fortschritt in Echtzeit.',
|
||||
},
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'KI-Coaching',
|
||||
description: 'Erhalte personalisierte Empfehlungen basierend auf deinem Ernährungsverlauf.',
|
||||
},
|
||||
{
|
||||
icon: '⭐',
|
||||
title: 'Favoriten',
|
||||
description: 'Speichere häufige Mahlzeiten und füge sie mit einem Klick hinzu.',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Maximaler Datenschutz',
|
||||
description: 'Deine Fotos werden nie gespeichert. Nur die Analyseergebnisse bleiben.',
|
||||
},
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Foto machen',
|
||||
description: 'Fotografiere deine Mahlzeit mit der Kamera oder wähle ein bestehendes Bild.',
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'KI analysiert',
|
||||
description: 'Unsere KI erkennt die Lebensmittel und berechnet alle Nährwerte in Sekunden.',
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Insights erhalten',
|
||||
description: 'Sieh deine Tagesbilanz, verfolge Trends und erhalte personalisierte Tipps.',
|
||||
},
|
||||
];
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Wie genau ist die KI-Analyse?',
|
||||
answer:
|
||||
'Unsere KI erreicht eine Genauigkeit von 85-95% bei der Erkennung von Lebensmitteln. Bei komplexen Gerichten zeigen wir dir einen Konfidenz-Score an.',
|
||||
},
|
||||
{
|
||||
question: 'Was passiert mit meinen Fotos?',
|
||||
answer:
|
||||
'Maximaler Datenschutz: Deine Fotos werden nach der Analyse sofort gelöscht und niemals auf unseren Servern gespeichert. Nur die Nährwertdaten werden gesichert.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich auch ohne Foto tracken?',
|
||||
answer:
|
||||
'Ja! Du kannst Mahlzeiten auch per Text eingeben. Die KI schätzt dann die Nährwerte basierend auf deiner Beschreibung.',
|
||||
},
|
||||
{
|
||||
question: 'Funktioniert die App mit allen Gerichten?',
|
||||
answer:
|
||||
'Food erkennt die meisten Gerichte weltweit, von klassischer deutscher Küche bis zu asiatischen Spezialitäten. Bei unbekannten Gerichten kannst du manuell nachhelfen.',
|
||||
},
|
||||
{
|
||||
question: 'Wie funktioniert das Credit-System?',
|
||||
answer:
|
||||
'Jede Foto-Analyse kostet 5 Credits, Text-Analysen 2 Credits. Du erhältst täglich kostenlose Credits, oder du nutzt Mana Premium für unbegrenzte Analysen.',
|
||||
},
|
||||
{
|
||||
question: 'Gibt es eine kostenlose Version?',
|
||||
answer:
|
||||
'Ja! Du kannst Food kostenlos nutzen mit täglich 3 Foto-Analysen. Für mehr Analysen und Premium-Features gibt es Mana Credits.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title="Food - Ernährung verstehen per Foto">
|
||||
<!-- Navigation -->
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-[#0F1F0F]/90 backdrop-blur border-b border-green-900/30"
|
||||
>
|
||||
<div class="container mx-auto px-4 max-w-6xl">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span class="text-white font-bold">N</span>
|
||||
</div>
|
||||
<span class="font-semibold text-white">Food</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://food.mana.how"
|
||||
class="px-4 py-2 bg-primary hover:bg-primary-hover text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Jetzt starten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<!-- Hero Section -->
|
||||
<section class="pt-32 pb-20 px-4">
|
||||
<div class="container mx-auto max-w-4xl text-center">
|
||||
<!-- Trust Badges -->
|
||||
<div class="flex flex-wrap justify-center gap-3 mb-8">
|
||||
<span
|
||||
class="px-3 py-1 bg-green-900/30 border border-green-800/50 rounded-full text-sm text-green-300"
|
||||
>
|
||||
🔒 Datenschutz-First
|
||||
</span>
|
||||
<span
|
||||
class="px-3 py-1 bg-green-900/30 border border-green-800/50 rounded-full text-sm text-green-300"
|
||||
>
|
||||
🤖 Powered by Gemini AI
|
||||
</span>
|
||||
<span
|
||||
class="px-3 py-1 bg-green-900/30 border border-green-800/50 rounded-full text-sm text-green-300"
|
||||
>
|
||||
✨ Kostenlos starten
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Fotografiere dein Essen.
|
||||
<span class="text-primary">Verstehe deinen Körper.</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-gray-300 mb-10 max-w-2xl mx-auto">
|
||||
Food analysiert deine Mahlzeiten per Foto und liefert dir sofort alle Nährwerte. Mit
|
||||
KI-Coaching erreichst du deine Gesundheitsziele.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="https://food.mana.how"
|
||||
class="px-8 py-4 bg-primary hover:bg-primary-hover text-white font-semibold rounded-xl transition-colors text-lg"
|
||||
>
|
||||
Kostenlos starten
|
||||
</a>
|
||||
<a
|
||||
href="#features"
|
||||
class="px-8 py-4 bg-white/10 hover:bg-white/20 text-white font-semibold rounded-xl transition-colors text-lg"
|
||||
>
|
||||
Mehr erfahren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-20 px-4 bg-[#1A2F1A]">
|
||||
<div class="container mx-auto max-w-6xl">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">Alles was du brauchst</h2>
|
||||
<p class="text-gray-300 max-w-2xl mx-auto">
|
||||
Food macht Ernährungstracking so einfach wie ein Foto.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{
|
||||
features.map((feature) => (
|
||||
<div class="p-6 bg-[#0F1F0F] border border-green-900/30 rounded-2xl hover:border-primary/50 transition-colors">
|
||||
<div class="text-4xl mb-4">{feature.icon}</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{feature.title}</h3>
|
||||
<p class="text-gray-400">{feature.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How it Works -->
|
||||
<section class="py-20 px-4">
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">So einfach geht's</h2>
|
||||
<p class="text-gray-300">In 3 Schritten zu besserer Ernährung</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
{
|
||||
steps.map((step, index) => (
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary flex items-center justify-center text-white font-bold text-xl">
|
||||
{step.number}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{step.title}</h3>
|
||||
<p class="text-gray-400">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section class="py-20 px-4 bg-[#1A2F1A]">
|
||||
<div class="container mx-auto max-w-3xl">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">Häufige Fragen</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{
|
||||
faqs.map((faq) => (
|
||||
<details class="group bg-[#0F1F0F] border border-green-900/30 rounded-xl">
|
||||
<summary class="flex items-center justify-between p-5 cursor-pointer list-none">
|
||||
<span class="font-medium text-white">{faq.question}</span>
|
||||
<span class="text-primary group-open:rotate-180 transition-transform">▼</span>
|
||||
</summary>
|
||||
<p class="px-5 pb-5 text-gray-400">{faq.answer}</p>
|
||||
</details>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="py-20 px-4">
|
||||
<div class="container mx-auto max-w-4xl text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">Starte jetzt mit Food</h2>
|
||||
<p class="text-xl text-gray-300 mb-10 max-w-2xl mx-auto">
|
||||
Kostenlos. Ohne Kreditkarte. Sofort loslegen.
|
||||
</p>
|
||||
<a
|
||||
href="https://food.mana.how"
|
||||
class="inline-block px-10 py-4 bg-primary hover:bg-primary-hover text-white font-semibold rounded-xl transition-colors text-lg"
|
||||
>
|
||||
Jetzt kostenlos registrieren
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-10 px-4 border-t border-green-900/30">
|
||||
<div class="container mx-auto max-w-6xl">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded bg-primary flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xs">N</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">Food by Mana</span>
|
||||
</div>
|
||||
<div class="flex gap-6 text-sm text-gray-400">
|
||||
<a href="/privacy" class="hover:text-white transition-colors">Datenschutz</a>
|
||||
<a href="/terms" class="hover:text-white transition-colors">AGB</a>
|
||||
<a href="/imprint" class="hover:text-white transition-colors">Impressum</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</Layout>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#22C55E',
|
||||
hover: '#16A34A',
|
||||
light: '#86EFAC',
|
||||
},
|
||||
secondary: '#F97316',
|
||||
accent: '#14B8A6',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
};
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
name = "food-landing"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"name": "food",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Food - AI-powered nutrition tracking with photo analysis",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@food/* --parallel dev",
|
||||
"dev:server": "pnpm --filter @food/server dev",
|
||||
"dev:web": "pnpm --filter @food/web dev",
|
||||
"dev:landing": "pnpm --filter @food/landing dev",
|
||||
"db:push": "pnpm --filter @food/server db:push",
|
||||
"db:studio": "pnpm --filter @food/server db:studio",
|
||||
"db:seed": "pnpm --filter @food/server db:seed",
|
||||
"test": "pnpm --filter @food/server test && pnpm --filter @food/shared test && pnpm --filter @food/web test",
|
||||
"test:backend": "pnpm --filter @food/server test",
|
||||
"test:web": "pnpm --filter @food/web test",
|
||||
"test:shared": "pnpm --filter @food/shared test",
|
||||
"test:cov": "pnpm --filter @food/server test:cov"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"name": "@food/shared",
|
||||
"version": "0.2.0",
|
||||
"type": "commonjs",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./utils": "./src/utils/index.ts",
|
||||
"./constants": "./src/constants/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
// Default daily recommended values (based on 2000 kcal diet)
|
||||
export const DEFAULT_DAILY_VALUES = {
|
||||
calories: 2000,
|
||||
protein: 50, // grams
|
||||
carbohydrates: 275, // grams
|
||||
fat: 78, // grams
|
||||
fiber: 28, // grams
|
||||
sugar: 50, // grams (max)
|
||||
saturatedFat: 20, // grams (max)
|
||||
// Vitamins
|
||||
vitaminA: 900, // µg RAE
|
||||
vitaminB1: 1.2, // mg
|
||||
vitaminB2: 1.3, // mg
|
||||
vitaminB3: 16, // mg
|
||||
vitaminB5: 5, // mg
|
||||
vitaminB6: 1.7, // mg
|
||||
vitaminB7: 30, // µg
|
||||
vitaminB9: 400, // µg
|
||||
vitaminB12: 2.4, // µg
|
||||
vitaminC: 90, // mg
|
||||
vitaminD: 20, // µg
|
||||
vitaminE: 15, // mg
|
||||
vitaminK: 120, // µg
|
||||
// Minerals
|
||||
calcium: 1000, // mg
|
||||
iron: 18, // mg
|
||||
magnesium: 420, // mg
|
||||
phosphorus: 700, // mg
|
||||
potassium: 4700, // mg
|
||||
sodium: 2300, // mg (max)
|
||||
zinc: 11, // mg
|
||||
copper: 0.9, // mg
|
||||
manganese: 2.3, // mg
|
||||
selenium: 55, // µg
|
||||
} as const;
|
||||
|
||||
// Meal type labels
|
||||
export const MEAL_TYPE_LABELS = {
|
||||
breakfast: {
|
||||
de: 'Frühstück',
|
||||
en: 'Breakfast',
|
||||
},
|
||||
lunch: {
|
||||
de: 'Mittagessen',
|
||||
en: 'Lunch',
|
||||
},
|
||||
dinner: {
|
||||
de: 'Abendessen',
|
||||
en: 'Dinner',
|
||||
},
|
||||
snack: {
|
||||
de: 'Snack',
|
||||
en: 'Snack',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Nutrient categories for UI grouping
|
||||
export const NUTRIENT_CATEGORIES = {
|
||||
macros: ['calories', 'protein', 'carbohydrates', 'fat', 'fiber', 'sugar'],
|
||||
vitamins: [
|
||||
'vitaminA',
|
||||
'vitaminB1',
|
||||
'vitaminB2',
|
||||
'vitaminB3',
|
||||
'vitaminB5',
|
||||
'vitaminB6',
|
||||
'vitaminB7',
|
||||
'vitaminB9',
|
||||
'vitaminB12',
|
||||
'vitaminC',
|
||||
'vitaminD',
|
||||
'vitaminE',
|
||||
'vitaminK',
|
||||
],
|
||||
minerals: [
|
||||
'calcium',
|
||||
'iron',
|
||||
'magnesium',
|
||||
'phosphorus',
|
||||
'potassium',
|
||||
'sodium',
|
||||
'zinc',
|
||||
'copper',
|
||||
'manganese',
|
||||
'selenium',
|
||||
],
|
||||
} as const;
|
||||
|
||||
// Nutrient display info
|
||||
export const NUTRIENT_INFO = {
|
||||
calories: { label: 'Kalorien', unit: 'kcal', color: '#F59E0B' },
|
||||
protein: { label: 'Protein', unit: 'g', color: '#EF4444' },
|
||||
carbohydrates: { label: 'Kohlenhydrate', unit: 'g', color: '#3B82F6' },
|
||||
fat: { label: 'Fett', unit: 'g', color: '#8B5CF6' },
|
||||
fiber: { label: 'Ballaststoffe', unit: 'g', color: '#10B981' },
|
||||
sugar: { label: 'Zucker', unit: 'g', color: '#EC4899' },
|
||||
vitaminA: { label: 'Vitamin A', unit: 'µg', color: '#F97316' },
|
||||
vitaminC: { label: 'Vitamin C', unit: 'mg', color: '#FBBF24' },
|
||||
vitaminD: { label: 'Vitamin D', unit: 'µg', color: '#A3E635' },
|
||||
calcium: { label: 'Calcium', unit: 'mg', color: '#E5E7EB' },
|
||||
iron: { label: 'Eisen', unit: 'mg', color: '#78716C' },
|
||||
magnesium: { label: 'Magnesium', unit: 'mg', color: '#06B6D4' },
|
||||
} as const;
|
||||
|
||||
// Credit costs per action
|
||||
export const CREDIT_COSTS = {
|
||||
photoAnalysis: 5,
|
||||
textAnalysis: 2,
|
||||
aiCoaching: 10,
|
||||
} as const;
|
||||
|
||||
// Theme colors
|
||||
export const FOOD_COLORS = {
|
||||
primary: '#22C55E', // Green 500
|
||||
primaryHover: '#16A34A', // Green 600
|
||||
primaryLight: '#86EFAC', // Green 300
|
||||
secondary: '#F97316', // Orange 500
|
||||
accent: '#14B8A6', // Teal 500
|
||||
background: '#0F1F0F', // Dark green tinted
|
||||
backgroundCard: '#1A2F1A',
|
||||
textPrimary: '#F0FDF4', // Green 50
|
||||
textSecondary: '#BBF7D0', // Green 200
|
||||
border: '#22543D', // Green 800
|
||||
} as const;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Constants
|
||||
export * from './constants';
|
||||
|
||||
// Utils
|
||||
export * from './utils';
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
// User Goals
|
||||
export interface UserGoals {
|
||||
id: string;
|
||||
userId: string;
|
||||
dailyCalories: number;
|
||||
dailyProtein?: number | null; // in grams
|
||||
dailyCarbs?: number | null;
|
||||
dailyFat?: number | null;
|
||||
dailyFiber?: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateUserGoalsDto {
|
||||
dailyCalories: number;
|
||||
dailyProtein?: number;
|
||||
dailyCarbs?: number;
|
||||
dailyFat?: number;
|
||||
dailyFiber?: number;
|
||||
}
|
||||
|
||||
// Meal Types
|
||||
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
export type InputType = 'photo' | 'text';
|
||||
|
||||
// Meal
|
||||
export interface Meal {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: Date;
|
||||
mealType: MealType;
|
||||
inputType: InputType;
|
||||
description: string; // AI-generated description of the meal
|
||||
portionSize?: string; // e.g., "small", "medium", "large" or grams
|
||||
confidence: number; // AI confidence score 0-1
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateMealDto {
|
||||
mealType: MealType;
|
||||
inputType: InputType;
|
||||
description?: string; // For text input
|
||||
imageBase64?: string; // For photo input
|
||||
portionSize?: string;
|
||||
}
|
||||
|
||||
// Nutrition Data
|
||||
export interface MealNutrition {
|
||||
id: string;
|
||||
mealId: string;
|
||||
// Macros
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
saturatedFat?: number | null;
|
||||
unsaturatedFat?: number | null;
|
||||
// Vitamins (in mg or µg as appropriate)
|
||||
vitaminA?: number | null; // µg RAE
|
||||
vitaminB1?: number | null; // mg (Thiamin)
|
||||
vitaminB2?: number | null; // mg (Riboflavin)
|
||||
vitaminB3?: number | null; // mg (Niacin)
|
||||
vitaminB5?: number | null; // mg (Pantothenic acid)
|
||||
vitaminB6?: number | null; // mg
|
||||
vitaminB7?: number | null; // µg (Biotin)
|
||||
vitaminB9?: number | null; // µg (Folate)
|
||||
vitaminB12?: number | null; // µg
|
||||
vitaminC?: number | null; // mg
|
||||
vitaminD?: number | null; // µg
|
||||
vitaminE?: number | null; // mg
|
||||
vitaminK?: number | null; // µg
|
||||
// Minerals (in mg)
|
||||
calcium?: number | null;
|
||||
iron?: number | null;
|
||||
magnesium?: number | null;
|
||||
phosphorus?: number | null;
|
||||
potassium?: number | null;
|
||||
sodium?: number | null;
|
||||
zinc?: number | null;
|
||||
copper?: number | null;
|
||||
manganese?: number | null;
|
||||
selenium?: number | null; // µg
|
||||
// Water
|
||||
water?: number | null; // ml
|
||||
}
|
||||
|
||||
// Favorite Meals
|
||||
export interface FavoriteMeal {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mealType: MealType;
|
||||
nutrition: Omit<MealNutrition, 'id' | 'mealId'>;
|
||||
usageCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateFavoriteMealDto {
|
||||
name: string;
|
||||
mealId?: string; // Create from existing meal
|
||||
description?: string;
|
||||
mealType?: MealType;
|
||||
}
|
||||
|
||||
// Daily Summary
|
||||
export interface DailySummary {
|
||||
date: Date;
|
||||
meals: Meal[];
|
||||
totalNutrition: Omit<MealNutrition, 'id' | 'mealId'>;
|
||||
goals?: UserGoals;
|
||||
progress: NutritionProgress;
|
||||
}
|
||||
|
||||
export interface NutritionProgress {
|
||||
calories: { current: number; target: number; percentage: number };
|
||||
protein?: { current: number; target: number; percentage: number };
|
||||
carbs?: { current: number; target: number; percentage: number };
|
||||
fat?: { current: number; target: number; percentage: number };
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
export type RecommendationType = 'hint' | 'coaching';
|
||||
export type RecommendationPriority = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: Date;
|
||||
type: RecommendationType;
|
||||
priority: RecommendationPriority;
|
||||
message: string;
|
||||
nutrient?: string; // e.g., 'protein', 'vitaminC'
|
||||
actionable?: string; // e.g., "Add more leafy greens"
|
||||
dismissed: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Weekly Stats
|
||||
export interface WeeklyStats {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
days: DailyStats[];
|
||||
averages: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
};
|
||||
trends: {
|
||||
caloriesTrend: 'up' | 'down' | 'stable';
|
||||
proteinTrend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
}
|
||||
|
||||
export interface DailyStats {
|
||||
date: Date;
|
||||
totalCalories: number;
|
||||
totalProtein: number;
|
||||
totalCarbs: number;
|
||||
totalFat: number;
|
||||
mealCount: number;
|
||||
goalsMet: boolean;
|
||||
}
|
||||
|
||||
// AI Analysis Response
|
||||
export interface AIAnalysisResult {
|
||||
foods: DetectedFood[];
|
||||
totalNutrition: Omit<MealNutrition, 'id' | 'mealId'>;
|
||||
description: string;
|
||||
confidence: number;
|
||||
warnings?: string[]; // e.g., "Could not identify one item"
|
||||
suggestions?: string[]; // e.g., "Consider adding more vegetables"
|
||||
}
|
||||
|
||||
export interface DetectedFood {
|
||||
name: string;
|
||||
quantity: string; // e.g., "150g", "1 cup"
|
||||
calories: number;
|
||||
confidence: number;
|
||||
source?: 'usda' | 'openfoodfacts' | 'ai_estimate';
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import { DEFAULT_DAILY_VALUES, NUTRIENT_INFO } from '../constants';
|
||||
import type { MealNutrition, NutritionProgress, UserGoals } from '../types';
|
||||
|
||||
/**
|
||||
* Calculate nutrition progress towards daily goals
|
||||
*/
|
||||
export function calculateProgress(
|
||||
totalNutrition: Partial<MealNutrition>,
|
||||
goals?: UserGoals
|
||||
): NutritionProgress {
|
||||
const targetCalories = goals?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories;
|
||||
const targetProtein = goals?.dailyProtein ?? DEFAULT_DAILY_VALUES.protein;
|
||||
const targetCarbs = goals?.dailyCarbs ?? DEFAULT_DAILY_VALUES.carbohydrates;
|
||||
const targetFat = goals?.dailyFat ?? DEFAULT_DAILY_VALUES.fat;
|
||||
|
||||
return {
|
||||
calories: {
|
||||
current: totalNutrition.calories ?? 0,
|
||||
target: targetCalories,
|
||||
percentage: Math.min(
|
||||
100,
|
||||
Math.round(((totalNutrition.calories ?? 0) / targetCalories) * 100)
|
||||
),
|
||||
},
|
||||
protein: {
|
||||
current: totalNutrition.protein ?? 0,
|
||||
target: targetProtein,
|
||||
percentage: Math.min(100, Math.round(((totalNutrition.protein ?? 0) / targetProtein) * 100)),
|
||||
},
|
||||
carbs: {
|
||||
current: totalNutrition.carbohydrates ?? 0,
|
||||
target: targetCarbs,
|
||||
percentage: Math.min(
|
||||
100,
|
||||
Math.round(((totalNutrition.carbohydrates ?? 0) / targetCarbs) * 100)
|
||||
),
|
||||
},
|
||||
fat: {
|
||||
current: totalNutrition.fat ?? 0,
|
||||
target: targetFat,
|
||||
percentage: Math.min(100, Math.round(((totalNutrition.fat ?? 0) / targetFat) * 100)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum up nutrition from multiple meals
|
||||
*/
|
||||
export function sumNutrition(
|
||||
meals: Array<{ nutrition?: Partial<MealNutrition> | null }>
|
||||
): Partial<MealNutrition> {
|
||||
const sum = {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbohydrates: 0,
|
||||
fat: 0,
|
||||
fiber: 0,
|
||||
sugar: 0,
|
||||
};
|
||||
|
||||
for (const meal of meals) {
|
||||
if (!meal.nutrition) continue;
|
||||
const n = meal.nutrition;
|
||||
if (typeof n.calories === 'number') sum.calories += n.calories;
|
||||
if (typeof n.protein === 'number') sum.protein += n.protein;
|
||||
if (typeof n.carbohydrates === 'number') sum.carbohydrates += n.carbohydrates;
|
||||
if (typeof n.fat === 'number') sum.fat += n.fat;
|
||||
if (typeof n.fiber === 'number') sum.fiber += n.fiber;
|
||||
if (typeof n.sugar === 'number') sum.sugar += n.sugar;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format nutrient value with unit
|
||||
*/
|
||||
export function formatNutrient(
|
||||
nutrient: keyof typeof NUTRIENT_INFO,
|
||||
value: number | undefined
|
||||
): string {
|
||||
if (value === undefined) return '-';
|
||||
const info = NUTRIENT_INFO[nutrient];
|
||||
if (!info) return `${value}`;
|
||||
|
||||
if (nutrient === 'calories') {
|
||||
return `${Math.round(value)} ${info.unit}`;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${info.unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for progress percentage
|
||||
*/
|
||||
export function getProgressColor(percentage: number): string {
|
||||
if (percentage < 50) return '#EF4444'; // Red
|
||||
if (percentage < 80) return '#F59E0B'; // Orange
|
||||
if (percentage <= 100) return '#22C55E'; // Green
|
||||
return '#EF4444'; // Red (over target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect deficiencies based on daily values
|
||||
*/
|
||||
export function detectDeficiencies(
|
||||
totalNutrition: Partial<MealNutrition>
|
||||
): Array<{ nutrient: string; percentage: number; label: string }> {
|
||||
const deficiencies: Array<{ nutrient: string; percentage: number; label: string }> = [];
|
||||
|
||||
const checks = [
|
||||
{ key: 'protein', threshold: 0.5 },
|
||||
{ key: 'fiber', threshold: 0.5 },
|
||||
{ key: 'vitaminC', threshold: 0.5 },
|
||||
{ key: 'vitaminD', threshold: 0.5 },
|
||||
{ key: 'iron', threshold: 0.5 },
|
||||
{ key: 'calcium', threshold: 0.5 },
|
||||
] as const;
|
||||
|
||||
for (const check of checks) {
|
||||
const value = totalNutrition[check.key as keyof typeof totalNutrition];
|
||||
const dailyValue = DEFAULT_DAILY_VALUES[check.key as keyof typeof DEFAULT_DAILY_VALUES];
|
||||
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
typeof dailyValue === 'number' &&
|
||||
value < dailyValue * check.threshold
|
||||
) {
|
||||
const info = NUTRIENT_INFO[check.key as keyof typeof NUTRIENT_INFO];
|
||||
deficiencies.push({
|
||||
nutrient: check.key,
|
||||
percentage: Math.round((value / dailyValue) * 100),
|
||||
label: info?.label ?? check.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return deficiencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meal type based on current time
|
||||
*/
|
||||
export function suggestMealType(): 'breakfast' | 'lunch' | 'dinner' | 'snack' {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
if (hour >= 5 && hour < 11) return 'breakfast';
|
||||
if (hour >= 11 && hour < 14) return 'lunch';
|
||||
if (hour >= 17 && hour < 21) return 'dinner';
|
||||
return 'snack';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
export function formatDateForDisplay(date: Date, locale = 'de-DE'): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if date is today
|
||||
*/
|
||||
export function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
calculateProgress,
|
||||
sumNutrition,
|
||||
formatNutrient,
|
||||
getProgressColor,
|
||||
detectDeficiencies,
|
||||
suggestMealType,
|
||||
formatDateForDisplay,
|
||||
isToday,
|
||||
} from './index';
|
||||
|
||||
describe('Shared Utils', () => {
|
||||
describe('calculateProgress', () => {
|
||||
it('should calculate progress with default values', () => {
|
||||
const nutrition = { calories: 1000, protein: 25, carbohydrates: 137, fat: 39 };
|
||||
const progress = calculateProgress(nutrition);
|
||||
|
||||
expect(progress.calories.current).toBe(1000);
|
||||
expect(progress.calories.target).toBe(2000);
|
||||
expect(progress.calories.percentage).toBe(50);
|
||||
});
|
||||
|
||||
it('should use custom goals', () => {
|
||||
const nutrition = { calories: 1500, protein: 75 };
|
||||
const goals = {
|
||||
dailyCalories: 3000,
|
||||
dailyProtein: 150,
|
||||
dailyCarbs: 300,
|
||||
dailyFat: 100,
|
||||
} as any;
|
||||
|
||||
const progress = calculateProgress(nutrition, goals);
|
||||
|
||||
expect(progress.calories.target).toBe(3000);
|
||||
expect(progress.calories.percentage).toBe(50);
|
||||
});
|
||||
|
||||
it('should cap percentage at 100', () => {
|
||||
const nutrition = { calories: 3000 };
|
||||
const progress = calculateProgress(nutrition);
|
||||
|
||||
expect(progress.calories.percentage).toBe(100);
|
||||
});
|
||||
|
||||
it('should handle missing values', () => {
|
||||
const progress = calculateProgress({});
|
||||
|
||||
expect(progress.calories.current).toBe(0);
|
||||
expect(progress.calories.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumNutrition', () => {
|
||||
it('should sum multiple meals', () => {
|
||||
const meals = [
|
||||
{ nutrition: { calories: 500, protein: 20, carbohydrates: 60, fat: 15 } },
|
||||
{ nutrition: { calories: 300, protein: 15, carbohydrates: 40, fat: 10 } },
|
||||
];
|
||||
|
||||
const sum = sumNutrition(meals);
|
||||
|
||||
expect(sum.calories).toBe(800);
|
||||
expect(sum.protein).toBe(35);
|
||||
expect(sum.carbohydrates).toBe(100);
|
||||
expect(sum.fat).toBe(25);
|
||||
});
|
||||
|
||||
it('should handle null nutrition', () => {
|
||||
const meals = [{ nutrition: { calories: 500 } }, { nutrition: null }];
|
||||
|
||||
const sum = sumNutrition(meals);
|
||||
|
||||
expect(sum.calories).toBe(500);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const sum = sumNutrition([]);
|
||||
|
||||
expect(sum.calories).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNutrient', () => {
|
||||
it('should format calories', () => {
|
||||
expect(formatNutrient('calories', 1234.5)).toBe('1235 kcal');
|
||||
});
|
||||
|
||||
it('should format protein', () => {
|
||||
expect(formatNutrient('protein', 25.5)).toBe('25.5 g');
|
||||
});
|
||||
|
||||
it('should return dash for undefined', () => {
|
||||
expect(formatNutrient('calories', undefined)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProgressColor', () => {
|
||||
it('should return red for low percentage', () => {
|
||||
expect(getProgressColor(30)).toBe('#EF4444');
|
||||
});
|
||||
|
||||
it('should return orange for medium percentage', () => {
|
||||
expect(getProgressColor(60)).toBe('#F59E0B');
|
||||
});
|
||||
|
||||
it('should return green for high percentage', () => {
|
||||
expect(getProgressColor(90)).toBe('#22C55E');
|
||||
});
|
||||
|
||||
it('should return red for over 100%', () => {
|
||||
expect(getProgressColor(120)).toBe('#EF4444');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectDeficiencies', () => {
|
||||
it('should detect low protein', () => {
|
||||
const nutrition = { protein: 10 }; // 20% of 50g target
|
||||
const deficiencies = detectDeficiencies(nutrition);
|
||||
|
||||
expect(deficiencies).toContainEqual(expect.objectContaining({ nutrient: 'protein' }));
|
||||
});
|
||||
|
||||
it('should not detect deficiency when above threshold', () => {
|
||||
const nutrition = { protein: 30 }; // 60% of target
|
||||
const deficiencies = detectDeficiencies(nutrition);
|
||||
|
||||
expect(deficiencies.find((d) => d.nutrient === 'protein')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestMealType', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should suggest breakfast in the morning', () => {
|
||||
vi.setSystemTime(new Date('2024-01-15T08:00:00'));
|
||||
expect(suggestMealType()).toBe('breakfast');
|
||||
});
|
||||
|
||||
it('should suggest lunch at noon', () => {
|
||||
vi.setSystemTime(new Date('2024-01-15T12:00:00'));
|
||||
expect(suggestMealType()).toBe('lunch');
|
||||
});
|
||||
|
||||
it('should suggest dinner in the evening', () => {
|
||||
vi.setSystemTime(new Date('2024-01-15T19:00:00'));
|
||||
expect(suggestMealType()).toBe('dinner');
|
||||
});
|
||||
|
||||
it('should suggest snack at other times', () => {
|
||||
vi.setSystemTime(new Date('2024-01-15T15:00:00'));
|
||||
expect(suggestMealType()).toBe('snack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateForDisplay', () => {
|
||||
it('should format date in German', () => {
|
||||
const date = new Date('2024-01-15');
|
||||
const formatted = formatDateForDisplay(date, 'de-DE');
|
||||
|
||||
expect(formatted).toContain('15');
|
||||
expect(formatted).toContain('Januar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToday', () => {
|
||||
it('should return true for today', () => {
|
||||
expect(isToday(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for yesterday', () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
expect(isToday(yesterday)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for same day different year', () => {
|
||||
const lastYear = new Date();
|
||||
lastYear.setFullYear(lastYear.getFullYear() - 1);
|
||||
expect(isToday(lastYear)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2021"],
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -252,7 +252,7 @@ The companion is a **second actor** that works alongside the human in every modu
|
|||
- **Actor attribution** — every event, record, and sync row carries `{ kind, principalId, displayName }` (+ mission/iteration/rationale for AI). `principalId` is the userId / agentId / `system:<source>` sentinel; `displayName` is cached at write time so rename doesn't rewrite history. Factories in `@mana/shared-ai/src/actor.ts`; runtime ambient context in `src/lib/data/events/actor.ts`.
|
||||
- **Agents** — named AI personas that own Missions. `/ai-agents` module for CRUD (policy editor, memory, budget, concurrency). Default "Mana" agent auto-bootstrapped on first login; legacy missions backfilled. `data/ai/agents/{store,queries,bootstrap}.ts`.
|
||||
- **AI policy** — per-tool `auto | propose | deny`. Lives on the agent (`agent.policy`). Proposable tool names come from `@mana/shared-ai`'s `AI_PROPOSABLE_TOOL_NAMES`; the mana-ai service runs a boot-time drift guard against the same list. Resolution in `src/lib/data/ai/policy.ts`; executor loads `agent.policy` for every AI write.
|
||||
- **Proposal inbox** — drop `<AiProposalInbox module="…" />` into any module page to render pending proposals inline with approve / freitext-reject buttons. Cards show the owning agent's name + avatar chip. Wired in `/todo`, `/calendar`, `/places`, `/drink`, `/food`, `/news`, `/notes`. The mission-detail view also embeds a **cross-module inbox** (`<AiProposalInbox missionId={id} />`): shows all pending proposals for that mission across all modules with a module-badge per card, so the user can review and approve without navigating to individual module pages.
|
||||
- **Proposal inbox** — drop `<AiProposalInbox module="…" />` into any module page to render pending proposals inline with approve / freitext-reject buttons. Cards show the owning agent's name + avatar chip. Wired in `/todo`, `/calendar`, `/places`, `/drink`, `/news`, `/notes`. The mission-detail view also embeds a **cross-module inbox** (`<AiProposalInbox missionId={id} />`): shows all pending proposals for that mission across all modules with a module-badge per card, so the user can review and approve without navigating to individual module pages.
|
||||
- **Reasoning loop** — the foreground Runner chains up to 5 planner calls per iteration. Read-only tools (`list_notes`, `get_task_stats`, etc.) execute inline as auto-policy, their outputs are fed back as synthetic `ResolvedInput`s for the next planner call. The loop exits when a propose-policy tool is staged (human must approve), the planner returns 0 steps, or the budget exhausts. This enables "read → reason → act" missions like *"list all notes and tag them"* in a single run. Code: `data/ai/missions/runner.ts` reasoning loop.
|
||||
- **Missions** — long-lived autonomous work items at `/ai-missions` with concept + objective + linked inputs + cadence + **owning agent** (AgentPicker in the create flow). Both the foreground tick AND the server-side `mana-ai` service produce plans under the agent's identity; `data/ai/missions/server-iteration-staging.ts` translates server-source iterations into local Proposals on sync.
|
||||
- **Input picker** — `<MissionInputPicker>` sources candidates from the `input-index` registry (notes / kontext / goals / tasks / calendar). The Runner resolves via the parallel `input-resolvers` registry. Encrypted tables (notes, tasks, …) decrypt client-side only.
|
||||
|
|
@ -272,7 +272,6 @@ Agents interact with the app through tools — each one either auto (executes si
|
|||
| notes | `create_note`, `update_note`, `append_to_note`, `add_tag_to_note` | `list_notes` |
|
||||
| places | `create_place`, `visit_place` | `get_places`, `get_current_location` |
|
||||
| drink | `undo_drink` | `get_drink_progress`, `log_drink` |
|
||||
| food | — | `nutrition_summary`, `log_meal` |
|
||||
| news | `save_news_article` | — |
|
||||
| news-research | `research_news` | — |
|
||||
| articles | `save_article`, `archive_article`, `tag_article`, `add_article_highlight`, `import_articles_from_urls` (auto) | `list_articles` |
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { setSecurityHeaders } from '@mana/shared-utils/security-headers';
|
|||
* - Glitchtip DSN → client-side error reporting
|
||||
*
|
||||
* Per-app HTTP backends (todo-api, calendar-api, contacts-api, chat-api,
|
||||
* storage-api, cards-api, mukke-api, food-api, picture-api, presi-api,
|
||||
* storage-api, cards-api, mukke-api, picture-api, presi-api,
|
||||
* quotes-api, clock-api, context-api) were removed in the pre-launch
|
||||
* ghost-API cleanup — every product module now talks to mana-sync directly.
|
||||
*/
|
||||
|
|
@ -152,18 +152,15 @@ const APP_SUBDOMAINS = new Set([
|
|||
'cards',
|
||||
'storage',
|
||||
'presi',
|
||||
'food',
|
||||
'photos',
|
||||
'music',
|
||||
'picture',
|
||||
'calc',
|
||||
'citycorners',
|
||||
'inventory',
|
||||
'times',
|
||||
'uload',
|
||||
'memoro',
|
||||
'questions',
|
||||
'moodlit',
|
||||
]);
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import {
|
|||
MusicNotes,
|
||||
Camera,
|
||||
HardDrives,
|
||||
ForkKnife,
|
||||
Plant,
|
||||
Presentation,
|
||||
Package,
|
||||
|
|
@ -34,7 +33,6 @@ import {
|
|||
NumberCircleOne,
|
||||
Binoculars,
|
||||
ArrowsInCardinal,
|
||||
SunHorizon,
|
||||
Buildings,
|
||||
DownloadSimple,
|
||||
Calculator,
|
||||
|
|
@ -76,7 +74,6 @@ import {
|
|||
Flask,
|
||||
Exam,
|
||||
Globe,
|
||||
CoatHanger,
|
||||
NotePencil,
|
||||
FilmStrip,
|
||||
Hourglass,
|
||||
|
|
@ -103,10 +100,10 @@ import {
|
|||
// Knowledge: chat · kontext · cards · quiz · guides ·
|
||||
// news · news-research · research-lab · articles ·
|
||||
// library · writing · comic · presi
|
||||
// Body & life: body · food · meditate · stretch · period ·
|
||||
// Body & life: body · meditate · stretch · period ·
|
||||
// dreams · firsts · lasts · habits · recipes
|
||||
// Places & ev.: places · citycorners · events · who
|
||||
// Creative: picture · music · photos · wardrobe · moodlit
|
||||
// Places & ev.: places · events · who
|
||||
// Creative: picture · music · photos
|
||||
// Tools: memoro · uload · calc · plants · inventory ·
|
||||
// storage · skilltree · questions
|
||||
// Long-tail: quotes · automations · companion · wetter ·
|
||||
|
|
@ -641,27 +638,6 @@ registerApp({
|
|||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'food',
|
||||
name: 'Food',
|
||||
color: '#22C55E',
|
||||
icon: ForkKnife,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/food/ListView.svelte') },
|
||||
},
|
||||
contextMenuActions: [
|
||||
{
|
||||
id: 'new-meal',
|
||||
label: 'Neue Mahlzeit',
|
||||
icon: Plus,
|
||||
action: () =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mana:quick-action', { detail: { app: 'food', action: 'new' } })
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'plants',
|
||||
name: 'Plants',
|
||||
|
|
@ -728,27 +704,6 @@ registerApp({
|
|||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'moodlit',
|
||||
name: 'Moodlit',
|
||||
color: '#F97316',
|
||||
icon: SunHorizon,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/moodlit/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'citycorners',
|
||||
name: 'CityCorners',
|
||||
color: '#14B8A6',
|
||||
icon: Buildings,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/citycorners/ListView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/citycorners/views/DetailView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'uload',
|
||||
name: 'uLoad',
|
||||
|
|
@ -1334,19 +1289,6 @@ registerApp({
|
|||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'wardrobe',
|
||||
name: 'Kleiderschrank',
|
||||
color: '#e11d48',
|
||||
icon: CoatHanger,
|
||||
views: {
|
||||
// Detail routes (/wardrobe/garment/[id], /wardrobe/outfit/[id],
|
||||
// /wardrobe/compose/[[outfitId]]) live as SvelteKit routes; the
|
||||
// workbench only needs the list view for the tab-switcher root.
|
||||
list: { load: () => import('$lib/modules/wardrobe/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'library',
|
||||
name: 'Bibliothek',
|
||||
|
|
|
|||
|
|
@ -68,13 +68,11 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
|
|||
meditate: 'life',
|
||||
rituals: 'life',
|
||||
journal: 'life',
|
||||
food: 'life',
|
||||
recipes: 'life',
|
||||
plants: 'life',
|
||||
finance: 'life',
|
||||
contacts: 'life',
|
||||
places: 'life',
|
||||
citycorners: 'life',
|
||||
news: 'life',
|
||||
wetter: 'life',
|
||||
inventory: 'life',
|
||||
|
|
@ -100,7 +98,6 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
|
|||
picture: 'creative',
|
||||
photos: 'creative',
|
||||
presi: 'creative',
|
||||
moodlit: 'creative',
|
||||
cards: 'creative',
|
||||
skilltree: 'creative',
|
||||
guides: 'creative',
|
||||
|
|
@ -109,7 +106,6 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
|
|||
library: 'creative',
|
||||
playground: 'creative',
|
||||
quiz: 'creative',
|
||||
wardrobe: 'creative',
|
||||
|
||||
// System — settings, admin, meta
|
||||
settings: 'system',
|
||||
|
|
|
|||
|
|
@ -267,19 +267,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
|||
'Dateinamen sind verschlüsselt, auch im Speicher nicht im Klartext sichtbar',
|
||||
],
|
||||
},
|
||||
food: {
|
||||
description: 'Mahlzeiten tracken mit AI-Unterstützung. Nährwerte werden automatisch erkannt.',
|
||||
features: [
|
||||
'Mahlzeiten per Text beschreiben',
|
||||
'Automatische Nährwertanalyse durch AI',
|
||||
'Tagesübersicht mit Kalorien & Makros',
|
||||
'AI-Tools: Mahlzeiten loggen, Tages-Zusammenfassung',
|
||||
],
|
||||
tips: [
|
||||
'Beschreibe Mahlzeiten natürlich: "2 Scheiben Vollkornbrot mit Käse und Tomate"',
|
||||
'Im Chat: "Was habe ich heute gegessen?"',
|
||||
],
|
||||
},
|
||||
plants: {
|
||||
description:
|
||||
'Pflanzen katalogisieren — Pflege-Notizen, Standort, Bewässerung und Bodentyp. Ideal für Hobbygärtner.',
|
||||
|
|
@ -357,30 +344,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
|||
],
|
||||
tips: ['Definiere Kategorien nach Lebensbereichen für eine gute Übersicht'],
|
||||
},
|
||||
moodlit: {
|
||||
description:
|
||||
'Stimmungslicht und Ambient-Szenen für Fokus und Entspannung. Verwandle deinen Bildschirm in eine Lichtquelle.',
|
||||
features: [
|
||||
'Verschiedene Licht-Szenen & Farbverläufe',
|
||||
'Timer-Funktion für zeitlich begrenzte Sessions',
|
||||
'Farbwechsel & Animationen',
|
||||
'Vollbild-Modus',
|
||||
],
|
||||
tips: ['Kombiniere Moodlit mit Meditate für eine immersive Meditationssession'],
|
||||
},
|
||||
citycorners: {
|
||||
description:
|
||||
'Interessante Ecken in deiner Stadt entdecken und festhalten. Ein persönlicher Stadtführer.',
|
||||
features: [
|
||||
'Orte mit Fotos & Beschreibung',
|
||||
'Kategorien (Café, Street Art, Architektur, ...)',
|
||||
'Standort & Adresse',
|
||||
'Entdeckungs-Feed',
|
||||
],
|
||||
tips: [
|
||||
'Halte Orte fest wenn du sie entdeckst — später erinnerst du dich nicht mehr an die Adresse',
|
||||
],
|
||||
},
|
||||
uload: {
|
||||
description:
|
||||
'Quick-Upload — Dateien schnell hochladen und teilbare Links erstellen. Ideal zum schnellen Teilen.',
|
||||
|
|
@ -980,23 +943,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
|||
'Ab ~8 Panels pro Story wird Character-Konsistenz spürbar schwerer (gpt-image-2-Limit).',
|
||||
],
|
||||
},
|
||||
wardrobe: {
|
||||
description:
|
||||
'Dein digitaler Kleiderschrank — fotografiere Kleidungsstücke und Accessoires, komponiere Outfits, und probiere sie mit KI an dir selbst an. Pro Space ein eigener Schrank: was im Family-Space liegt, taucht im Brand-Space nicht auf.',
|
||||
features: [
|
||||
'Kleidung nach Kategorien (Oberteile, Hosen, Kleider, Jacken, Schuhe, Accessoires …)',
|
||||
'Outfits aus mehreren Stücken komponieren und als Set anprobieren',
|
||||
'Solo-Try-On pro Einzelstück — Accessoire-Modus (Brille, Schmuck, Hut) rendert nur das Gesicht und spart Credits',
|
||||
'Referenzbilder aus „Meine Bilder" (Gesicht + optional Ganzkörper) werden automatisch genutzt',
|
||||
'MCP-Tools: listGarments / listOutfits / createOutfit / tryOn für Agents',
|
||||
],
|
||||
tips: [
|
||||
'Aktive Kategorie oben bestimmt den Typ für neue Uploads — erst die Kategorie wählen, dann die Datei droppen.',
|
||||
'Die Upload-Zone oben akzeptiert Drag-&-Drop direkt aus dem Finder.',
|
||||
'Frontal-Fotos mit hellem Hintergrund liefern die besten Try-On-Ergebnisse.',
|
||||
'Ohne Gesichtsbild kannst du kein Try-On starten — der Banner oben hilft beim Upload in einem Schritt.',
|
||||
],
|
||||
},
|
||||
'research-lab': {
|
||||
description:
|
||||
'Web-Research-Anbieter Seite-an-Seite vergleichen: gleiche Query an bis zu fünf Provider parallel, Antworten + Latenz + Kosten nebeneinander. Alle Runs werden serverseitig persistiert für spätere Auswertung.',
|
||||
|
|
|
|||
|
|
@ -88,22 +88,6 @@ export const GOAL_TEMPLATES: GoalTemplate[] = [
|
|||
metric: { source: 'event_count', eventType: 'TaskCompleted' },
|
||||
target: { value: 5, period: 'day', comparison: 'gte' },
|
||||
},
|
||||
{
|
||||
id: 'tpl-meals-daily',
|
||||
title: 'Alle Mahlzeiten tracken',
|
||||
description: 'Mindestens 3 Mahlzeiten pro Tag erfassen',
|
||||
moduleId: 'food',
|
||||
metric: { source: 'event_count', eventType: 'MealLogged' },
|
||||
target: { value: 3, period: 'day', comparison: 'gte' },
|
||||
},
|
||||
{
|
||||
id: 'tpl-calories-daily',
|
||||
title: 'Kalorien-Ziel einhalten',
|
||||
description: 'Maximal 2000 kcal pro Tag',
|
||||
moduleId: 'food',
|
||||
metric: { source: 'event_sum', eventType: 'MealLogged', sumField: 'calories' },
|
||||
target: { value: 2000, period: 'day', comparison: 'lte' },
|
||||
},
|
||||
{
|
||||
id: 'tpl-places-weekly',
|
||||
title: 'Neue Orte entdecken',
|
||||
|
|
|
|||
|
|
@ -206,10 +206,6 @@ export async function extractAllPatterns(): Promise<void> {
|
|||
// Calendar patterns
|
||||
extractDayOfWeekPattern('CalendarEventCreated', 'Termine erstellt', 'calendar'),
|
||||
|
||||
// Food patterns
|
||||
extractTimePreference('MealLogged', 'Mahlzeiten geloggt', 'food'),
|
||||
extractFrequencyPattern('MealLogged', 'Mahlzeiten', 'food'),
|
||||
|
||||
// Places patterns
|
||||
extractDayOfWeekPattern('PlaceVisited', 'Orte besucht', 'places'),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -106,48 +106,10 @@ export const overdueTasksRule: PulseRule = {
|
|||
},
|
||||
};
|
||||
|
||||
export const mealReminderRule: PulseRule = {
|
||||
id: 'meal-reminder',
|
||||
name: 'Mahlzeit-Erinnerung',
|
||||
trigger: { kind: 'schedule', hours: [12, 19] },
|
||||
check(ctx) {
|
||||
const { meals, calories } = ctx.day.nutrition;
|
||||
|
||||
// Lunch check at 12
|
||||
if (ctx.hour === 12 && meals < 1) {
|
||||
return {
|
||||
id: `meal-lunch-${ctx.day.date}`,
|
||||
type: 'meal_reminder',
|
||||
title: 'Mittagessen tracken',
|
||||
body: 'Noch keine Mahlzeit heute erfasst.',
|
||||
priority: 'low',
|
||||
actionLabel: 'Mahlzeit loggen',
|
||||
actionRoute: '/food',
|
||||
};
|
||||
}
|
||||
|
||||
// Dinner check at 19
|
||||
if (ctx.hour === 19 && meals < 2) {
|
||||
return {
|
||||
id: `meal-dinner-${ctx.day.date}`,
|
||||
type: 'meal_reminder',
|
||||
title: 'Abendessen tracken',
|
||||
body: `Erst ${meals} Mahlzeit(en) heute (${calories.actual} kcal).`,
|
||||
priority: 'low',
|
||||
actionLabel: 'Mahlzeit loggen',
|
||||
actionRoute: '/food',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/** All built-in rules */
|
||||
export const DEFAULT_RULES: PulseRule[] = [
|
||||
waterReminderRule,
|
||||
streakWarningRule,
|
||||
morningSummaryRule,
|
||||
overdueTasksRule,
|
||||
mealReminderRule,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
|
|||
// Phase 4: Unified app widgets (direct Dexie queries, internal routing)
|
||||
import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte';
|
||||
import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelte';
|
||||
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
|
||||
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
|
||||
import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
|
||||
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
|
||||
|
|
@ -55,7 +54,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
|
|||
'music-library': MusicLibraryWidget,
|
||||
'presi-decks': PresiDecksWidget,
|
||||
'active-timer': ActiveTimerWidget,
|
||||
'nutrition-progress': NutritionProgressWidget,
|
||||
'plant-watering': PlantWateringWidget,
|
||||
'day-timeline': DayTimelineWidget,
|
||||
'activity-feed': ActivityFeedWidget,
|
||||
|
|
|
|||
|
|
@ -498,45 +498,6 @@ export const appConfigs: Record<string, AppConfig> = {
|
|||
dashboardRoute: '/',
|
||||
website: 'https://storage.mana.how',
|
||||
},
|
||||
|
||||
moodlit: {
|
||||
name: 'moodlit',
|
||||
displayName: 'Moodlit',
|
||||
tagline: 'Ambient Lighting & Moods',
|
||||
description:
|
||||
'Erstelle beruhigende Lichtstimmungen mit animierten Farbverläufen für entspannte Atmosphäre.',
|
||||
logoEmoji: '🌈',
|
||||
primaryColor: '#8B5CF6',
|
||||
accentColor: '#A78BFA',
|
||||
features: [
|
||||
{
|
||||
icon: '🌈',
|
||||
title: 'Farbverläufe',
|
||||
description: 'Animierte Ambient-Beleuchtung',
|
||||
color: '#8B5CF6',
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Themes',
|
||||
description: 'Vordefinierte Stimmungen',
|
||||
color: '#EC4899',
|
||||
},
|
||||
{
|
||||
icon: '✨',
|
||||
title: 'Animationen',
|
||||
description: 'Sanfte, beruhigende Bewegungen',
|
||||
color: '#F59E0B',
|
||||
},
|
||||
{
|
||||
icon: '🌙',
|
||||
title: 'Nachtmodus',
|
||||
description: 'Perfekt zum Einschlafen',
|
||||
color: '#6366F1',
|
||||
},
|
||||
],
|
||||
dashboardRoute: '/',
|
||||
website: 'https://moodlit.mana.how',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -631,6 +592,6 @@ export function getAppsByCategory(): {
|
|||
appConfigs.contacts,
|
||||
appConfigs.finance,
|
||||
],
|
||||
utility: [appConfigs.clock, appConfigs.quotes, appConfigs.storage, appConfigs.moodlit],
|
||||
utility: [appConfigs.clock, appConfigs.quotes, appConfigs.storage],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@ import type {
|
|||
} from '../../modules/broadcasts/types';
|
||||
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
|
||||
import type { LocalMeImage } from '../../modules/profile/types';
|
||||
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
|
||||
import type {
|
||||
LocalDraft,
|
||||
LocalDraftVersion,
|
||||
|
|
@ -194,29 +193,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
periods: { enabled: true, fields: ['notes'] },
|
||||
periodDayLogs: { enabled: true, fields: ['notes', 'mood'] },
|
||||
|
||||
// ─── Food ────────────────────────────────────────────
|
||||
// LocalMeal user-typed / AI-generated content → encrypted:
|
||||
// - description, portionSize: free-text, same sensitivity tier
|
||||
// - foods: AI-identified food items (array of {name, quantity,
|
||||
// calories}). aes.ts JSON-stringifies before wrap, so an array
|
||||
// value works the same as a string. The food names are user
|
||||
// content ("Currywurst Pommes mittel") and deserve the same
|
||||
// protection as `description`.
|
||||
// Plaintext (intentional):
|
||||
// - mealType / inputType / date / createdAt: structural, used for
|
||||
// filtering and the daily-summary aggregations + calorie-progress
|
||||
// widget. Encrypting would force decrypt-then-aggregate on every
|
||||
// liveQuery refresh.
|
||||
// - nutrition (object of numbers): same — calorie totals are summed
|
||||
// in pure $derived helpers; encrypting them would defeat the
|
||||
// local-first reactive layer.
|
||||
// - photoMediaId / photoUrl / photoThumbnailUrl: opaque pointers to
|
||||
// mana-media; the URL alone is not PII (anyone with the URL
|
||||
// already has the bytes), and CAS-deduped media IDs leak no user
|
||||
// content. Same rationale plants uses for plantPhotos.
|
||||
// - confidence (float 0-1): pure metadata about the AI run.
|
||||
meals: { enabled: true, fields: ['description', 'portionSize', 'foods'] },
|
||||
|
||||
// ─── Plants ──────────────────────────────────────────────
|
||||
// `name` is NOT in the schema index for plants (only isActive +
|
||||
// healthStatus), so encrypting it is safe. LocalPlant uses
|
||||
|
|
@ -582,33 +558,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// lives in MinIO behind owner-RLS, not in Dexie.
|
||||
meImages: entry<LocalMeImage>(['label', 'tags']),
|
||||
|
||||
// ─── Wardrobe (garments + outfits) ───────────────────────
|
||||
// docs/plans/wardrobe-module.md M1. Two space-scoped tables.
|
||||
//
|
||||
// Garments: user-typed clothing metadata is the sensitive surface —
|
||||
// brand names leak purchasing patterns, notes leak preferences,
|
||||
// tags leak categorization intent. `category` stays plaintext
|
||||
// because it's the Category-Tabs filter index; `mediaIds`, dates,
|
||||
// and counters are structural.
|
||||
wardrobeGarments: entry<LocalWardrobeGarment>([
|
||||
'name',
|
||||
'brand',
|
||||
'color',
|
||||
'size',
|
||||
'material',
|
||||
'tags',
|
||||
'notes',
|
||||
]),
|
||||
// Outfits: name + description + tags are user-authored. Occasion
|
||||
// stays plaintext (closed enum, small cardinality — useful to
|
||||
// filter on without decrypt). `garmentIds` is an array of FKs,
|
||||
// plaintext by the standard "IDs are plaintext" rule. `lastTryOn`
|
||||
// is a structural pointer + prompt; the prompt itself isn't
|
||||
// secret (OpenAI already saw it) but lands inside the encrypted
|
||||
// JSON-stringified blob via the `season` array-path anyway — keep
|
||||
// it plaintext and revisit if prompts later carry personal data.
|
||||
wardrobeOutfits: entry<LocalWardrobeOutfit>(['name', 'description', 'tags']),
|
||||
|
||||
// ─── Comic (stories + inline panel metadata) ─────────────
|
||||
// docs/plans/comic-module.md M1. Single space-scoped table.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -141,45 +141,6 @@ export interface DrinkEntryUndonePayload {
|
|||
|
||||
export type DrinkEventType = 'DrinkLogged' | 'DrinkEntryDeleted' | 'DrinkEntryUndone';
|
||||
|
||||
// ── Food ────────────────────────────────────────
|
||||
|
||||
export interface MealLoggedPayload {
|
||||
mealId: string;
|
||||
mealType: string;
|
||||
inputType: string;
|
||||
description: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface MealFromPhotoLoggedPayload {
|
||||
mealId: string;
|
||||
mealType: string;
|
||||
photoMediaId: string;
|
||||
confidence: number;
|
||||
calories?: number;
|
||||
}
|
||||
|
||||
export interface MealDeletedPayload {
|
||||
mealId: string;
|
||||
mealType: string;
|
||||
}
|
||||
|
||||
export interface NutritionGoalSetPayload {
|
||||
goalId: string;
|
||||
dailyCalories: number;
|
||||
dailyProtein?: number;
|
||||
dailyCarbs?: number;
|
||||
dailyFat?: number;
|
||||
}
|
||||
|
||||
export type FoodEventType =
|
||||
| 'MealLogged'
|
||||
| 'MealFromPhotoLogged'
|
||||
| 'MealDeleted'
|
||||
| 'NutritionGoalSet';
|
||||
|
||||
// ── Places ──────────────────────────────────────────
|
||||
|
||||
export interface PlaceCreatedPayload {
|
||||
|
|
@ -661,7 +622,6 @@ export type ManaEventType =
|
|||
| TodoEventType
|
||||
| CalendarEventType
|
||||
| DrinkEventType
|
||||
| FoodEventType
|
||||
| PlacesEventType
|
||||
| HabitsEventType
|
||||
| JournalEventType
|
||||
|
|
@ -717,11 +677,6 @@ export type ManaEvent =
|
|||
| DomainEvent<'DrinkLogged', DrinkLoggedPayload>
|
||||
| DomainEvent<'DrinkEntryDeleted', DrinkEntryDeletedPayload>
|
||||
| DomainEvent<'DrinkEntryUndone', DrinkEntryUndonePayload>
|
||||
// Food
|
||||
| DomainEvent<'MealLogged', MealLoggedPayload>
|
||||
| DomainEvent<'MealFromPhotoLogged', MealFromPhotoLoggedPayload>
|
||||
| DomainEvent<'MealDeleted', MealDeletedPayload>
|
||||
| DomainEvent<'NutritionGoalSet', NutritionGoalSetPayload>
|
||||
// Places
|
||||
| DomainEvent<'PlaceCreated', PlaceCreatedPayload>
|
||||
| DomainEvent<'PlaceDeleted', PlaceDeletedPayload>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@
|
|||
* the matching index in `database.ts` (or vice versa), one of these tests
|
||||
* fails loudly instead of letting sync silently drop the table.
|
||||
*
|
||||
* The "snapshot" tests pin the *exact* registry shape that existed before
|
||||
* the refactor. Any intentional change to a module's tables / sync names
|
||||
* should update both the module config AND the corresponding entry below
|
||||
* in the same commit — this makes such changes visible in code review.
|
||||
* The "snapshot" tests pin the *exact* registry shape that exists today.
|
||||
* Any intentional change to a module's tables / sync names should update
|
||||
* both the module config AND the corresponding entry below in the same
|
||||
* commit — this makes such changes visible in code review.
|
||||
*
|
||||
* Last full snapshot refresh: 2026-05-18 (food + wardrobe module retirement;
|
||||
* citycorners + cards modules already retired before).
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
|
|
@ -35,17 +38,73 @@ import {
|
|||
import { db } from './database';
|
||||
|
||||
// ─── Internal Dexie tables that are intentionally NOT in SYNC_APP_MAP ───
|
||||
// These hold local-only state (sync metadata, retry queues, activity log)
|
||||
// that must never leave the device.
|
||||
// These hold local-only state (sync metadata, retry queues, activity log,
|
||||
// AI debug capture, BYOK key material, …) that must never leave the device.
|
||||
const INTERNAL_TABLES = new Set([
|
||||
'_pendingChanges',
|
||||
'_syncMeta',
|
||||
'_eventsTombstones',
|
||||
'_activity',
|
||||
'_events',
|
||||
'_memory',
|
||||
'_streakState',
|
||||
'_aiDebugLog',
|
||||
'_byokKeys',
|
||||
'_clientIdentity',
|
||||
'_nudgeOutcomes',
|
||||
'_serverIterationExecutions',
|
||||
// Local-only AI Workbench staging; approvals run the underlying tool
|
||||
// which writes via its module's sync path — proposals themselves never
|
||||
// leave the device.
|
||||
'pendingProposals',
|
||||
// Local-only news feed cache.
|
||||
'newsCachedFeed',
|
||||
]);
|
||||
|
||||
// ─── Dexie tables that survive in the schema for backwards-compat with
|
||||
// existing user databases, but whose owning module has been retired and
|
||||
// is no longer expected to register them. These rows are stranded until
|
||||
// a future Dexie version() call drops them explicitly. Tracked here so
|
||||
// the "every Dexie table is registered" guard doesn't break on legacy
|
||||
// schema history.
|
||||
const LEGACY_TABLES = new Set([
|
||||
// Cards → wordeck.com (2026-05-17 rebrand)
|
||||
'cardDecks',
|
||||
'cards',
|
||||
'deckTags',
|
||||
'cardReviews',
|
||||
'cardStudyBlocks',
|
||||
// CityCorners → seepuls.mana.how (2026-05 retired)
|
||||
'cities',
|
||||
'ccLocations',
|
||||
'ccFavorites',
|
||||
'ccLocationTags',
|
||||
// Moodlit → mood module split (legacy tables still in Dexie history)
|
||||
'moods',
|
||||
'sequences',
|
||||
'moodTags',
|
||||
// Companion module — surfaces still live but tables predate the
|
||||
// per-module registry refactor; tracked in the agents/missions registry
|
||||
// today.
|
||||
'companionConversations',
|
||||
'companionGoals',
|
||||
'companionMessages',
|
||||
// Rituals — local-only state for the AI Workbench ritual runner; not
|
||||
// yet promoted into a module config but writes happen via the workbench
|
||||
// pathway.
|
||||
'rituals',
|
||||
'ritualSteps',
|
||||
'ritualLogs',
|
||||
// Wishes — module surface exists, registry adoption pending.
|
||||
'wishesItems',
|
||||
'wishesLists',
|
||||
'wishesPriceChecks',
|
||||
// Who — module surface exists, registry adoption pending.
|
||||
'whoGames',
|
||||
'whoMessages',
|
||||
// User-level legacy table from the v40 tag-preset migration; lives
|
||||
// outside the module-registry by design (cross-module shared shape).
|
||||
'userTagPresets',
|
||||
]);
|
||||
|
||||
describe('module-registry — structural invariants', () => {
|
||||
|
|
@ -114,10 +173,11 @@ describe('module-registry — Dexie schema alignment', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('every Dexie table is either internal or registered with an appId', () => {
|
||||
it('every Dexie table is either internal, legacy, or registered with an appId', () => {
|
||||
const registered = new Set(Object.keys(TABLE_TO_APP));
|
||||
for (const t of db.tables) {
|
||||
if (INTERNAL_TABLES.has(t.name)) continue;
|
||||
if (LEGACY_TABLES.has(t.name)) continue;
|
||||
expect(
|
||||
registered.has(t.name),
|
||||
`Dexie table "${t.name}" is not registered in any module.config.ts — sync will silently skip it`
|
||||
|
|
@ -128,29 +188,29 @@ describe('module-registry — Dexie schema alignment', () => {
|
|||
|
||||
// ─── Snapshot of the registry shape ───────────────────────────────
|
||||
//
|
||||
// This is the exact set of (appId → tables) and (unified → sync) mappings
|
||||
// that the legacy hardcoded blocks in database.ts had pre-refactor. If you
|
||||
// intentionally change a module's sync surface, update the matching entry
|
||||
// here in the same commit so the change is reviewable.
|
||||
// Exact (appId → tables) and (unified → sync) shape of the current registry.
|
||||
// If you intentionally change a module's sync surface, update the matching
|
||||
// entry here in the same commit so the change is reviewable.
|
||||
|
||||
describe('module-registry — pre-refactor snapshot', () => {
|
||||
it('SYNC_APP_MAP matches the legacy hardcoded shape', () => {
|
||||
describe('module-registry — snapshot', () => {
|
||||
it('SYNC_APP_MAP matches the current registry shape', () => {
|
||||
expect(SYNC_APP_MAP).toEqual({
|
||||
mana: ['userSettings', 'dashboardConfigs', 'automations'],
|
||||
mana: ['userSettings', 'dashboardConfigs', 'workbenchScenes', 'automations'],
|
||||
tags: ['globalTags', 'tagGroups'],
|
||||
links: ['manaLinks'],
|
||||
timeblocks: ['timeBlocks', 'timeBlockTags'],
|
||||
todo: ['tasks', 'todoProjects', 'taskLabels', 'reminders', 'boardViews'],
|
||||
calendar: ['calendars', 'events', 'eventTags'],
|
||||
contacts: ['contacts', 'contactTags'],
|
||||
chat: ['conversations', 'messages', 'chatTemplates', 'conversationTags'],
|
||||
picture: ['images', 'boards', 'boardItems', 'imageTags'],
|
||||
cards: ['cardDecks', 'cards', 'deckTags'],
|
||||
quotes: ['quotesFavorites', 'quotesLists', 'quotesListTags'],
|
||||
quotes: ['quotesFavorites', 'quotesLists', 'quotesListTags', 'customQuotes'],
|
||||
music: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers', 'songTags'],
|
||||
storage: ['files', 'storageFolders', 'fileTags'],
|
||||
presi: ['presiDecks', 'slides', 'presiDeckTags'],
|
||||
inventory: ['invCollections', 'invItems', 'invLocations', 'invCategories', 'invItemTags'],
|
||||
photos: ['albums', 'albumItems', 'photoFavorites', 'photoMediaTags'],
|
||||
skilltree: ['skills', 'activities', 'achievements', 'skillTags'],
|
||||
citycorners: ['cities', 'ccLocations', 'ccFavorites', 'ccLocationTags'],
|
||||
times: [
|
||||
'timeClients',
|
||||
'timeProjects',
|
||||
|
|
@ -163,33 +223,77 @@ describe('module-registry — pre-refactor snapshot', () => {
|
|||
'entryTags',
|
||||
],
|
||||
questions: ['qCollections', 'questions', 'answers', 'questionTags'],
|
||||
food: ['meals', 'goals', 'foodFavorites', 'mealTags'],
|
||||
plants: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'],
|
||||
uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'],
|
||||
calc: ['calculations', 'savedFormulas'],
|
||||
moodlit: ['moods', 'sequences', 'moodTags'],
|
||||
memoro: ['memos', 'memories', 'memoTags', 'memoroSpaces', 'spaceMembers', 'memoSpaces'],
|
||||
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'],
|
||||
habits: ['habits', 'habitLogs'],
|
||||
notes: ['notes', 'noteTags'],
|
||||
journal: ['journalEntries'],
|
||||
dreams: ['dreams', 'dreamSymbols', 'dreamTags'],
|
||||
period: ['periods', 'periodDayLogs', 'periodSymptoms'],
|
||||
events: ['socialEvents', 'eventGuests', 'eventInvitations', 'eventItems'],
|
||||
finance: ['transactions', 'financeCategories', 'budgets'],
|
||||
places: ['places', 'locationLogs', 'placeTags'],
|
||||
tags: ['globalTags', 'tagGroups'],
|
||||
links: ['manaLinks'],
|
||||
timeblocks: ['timeBlocks', 'timeBlockTags'],
|
||||
playground: ['playgroundSnippets', 'playgroundConversations', 'playgroundMessages'],
|
||||
news: ['newsArticles', 'newsCategories', 'newsPreferences', 'newsReactions'],
|
||||
body: [
|
||||
'bodyExercises',
|
||||
'bodyRoutines',
|
||||
'bodyWorkouts',
|
||||
'bodySets',
|
||||
'bodyMeasurements',
|
||||
'bodyChecks',
|
||||
'bodyPhases',
|
||||
],
|
||||
firsts: ['firsts'],
|
||||
lasts: ['lasts', 'lastsCooldown'],
|
||||
drink: ['drinkEntries', 'drinkPresets'],
|
||||
recipes: ['recipes'],
|
||||
stretch: [
|
||||
'stretchExercises',
|
||||
'stretchRoutines',
|
||||
'stretchSessions',
|
||||
'stretchAssessments',
|
||||
'stretchReminders',
|
||||
],
|
||||
mail: ['mailDrafts'],
|
||||
meditate: ['meditatePresets', 'meditateSessions', 'meditateSettings'],
|
||||
sleep: ['sleepEntries', 'sleepHygieneLogs', 'sleepHygieneChecks', 'sleepSettings'],
|
||||
mood: ['moodEntries', 'moodSettings'],
|
||||
quiz: ['quizzes', 'quizQuestions', 'quizAttempts'],
|
||||
profile: ['userContext', 'meImages'],
|
||||
library: ['libraryEntries'],
|
||||
articles: [
|
||||
'articles',
|
||||
'articleHighlights',
|
||||
'articleTags',
|
||||
'articleImportJobs',
|
||||
'articleImportItems',
|
||||
'articleExtractPickup',
|
||||
],
|
||||
invoices: ['invoices', 'invoiceClients', 'invoiceSettings'],
|
||||
broadcasts: ['broadcastCampaigns', 'broadcastTemplates', 'broadcastSettings'],
|
||||
wetter: ['wetterLocations', 'wetterSettings'],
|
||||
website: ['websites', 'websitePages', 'websiteBlocks'],
|
||||
writing: ['writingDrafts', 'writingDraftVersions', 'writingGenerations', 'writingStyles'],
|
||||
comic: ['comicStories', 'comicCharacters'],
|
||||
augur: ['augurEntries'],
|
||||
forms: ['forms', 'formResponses'],
|
||||
ai: ['aiMissions', 'agents', 'agentKontextDocs'],
|
||||
});
|
||||
});
|
||||
|
||||
it('TABLE_TO_SYNC_NAME matches the legacy hardcoded shape', () => {
|
||||
it('TABLE_TO_SYNC_NAME matches the current registry shape', () => {
|
||||
expect(TABLE_TO_SYNC_NAME).toEqual({
|
||||
globalTags: 'tags',
|
||||
manaLinks: 'links',
|
||||
todoProjects: 'projects',
|
||||
chatTemplates: 'templates',
|
||||
cardDecks: 'decks',
|
||||
quotesFavorites: 'favorites',
|
||||
quotesLists: 'lists',
|
||||
customQuotes: 'custom-quotes',
|
||||
mukkePlaylists: 'playlists',
|
||||
mukkeProjects: 'projects',
|
||||
storageFolders: 'folders',
|
||||
|
|
@ -200,8 +304,6 @@ describe('module-registry — pre-refactor snapshot', () => {
|
|||
invCategories: 'categories',
|
||||
photoFavorites: 'favorites',
|
||||
photoMediaTags: 'photoTags',
|
||||
ccLocations: 'locations',
|
||||
ccFavorites: 'favorites',
|
||||
timeClients: 'clients',
|
||||
timeProjects: 'projects',
|
||||
timeTemplates: 'templates',
|
||||
|
|
@ -210,18 +312,27 @@ describe('module-registry — pre-refactor snapshot', () => {
|
|||
timeCountdownTimers: 'countdownTimers',
|
||||
timeWorldClocks: 'worldClocks',
|
||||
qCollections: 'collections',
|
||||
foodFavorites: 'favorites',
|
||||
memoroSpaces: 'spaces',
|
||||
uloadTags: 'tags',
|
||||
uloadFolders: 'folders',
|
||||
memoroSpaces: 'spaces',
|
||||
guideCollections: 'collections',
|
||||
financeCategories: 'categories',
|
||||
socialEvents: 'events',
|
||||
globalTags: 'tags',
|
||||
// `tagGroups` is intentionally absent — it has no rename in the registry
|
||||
// (the legacy hardcoded block had a redundant tagGroups→tagGroups entry
|
||||
// which was a no-op; toSyncName() returns the same value either way).
|
||||
manaLinks: 'links',
|
||||
financeCategories: 'categories',
|
||||
playgroundSnippets: 'snippets',
|
||||
playgroundConversations: 'conversations',
|
||||
playgroundMessages: 'messages',
|
||||
newsArticles: 'articles',
|
||||
newsCategories: 'categories',
|
||||
newsPreferences: 'preferences',
|
||||
newsReactions: 'reactions',
|
||||
quizQuestions: 'questions',
|
||||
quizAttempts: 'attempts',
|
||||
articleHighlights: 'highlights',
|
||||
articleImportJobs: 'importJobs',
|
||||
articleImportItems: 'importItems',
|
||||
articleExtractPickup: 'extractPickup',
|
||||
wetterLocations: 'locations',
|
||||
wetterSettings: 'settings',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,14 +63,11 @@ import { presiModuleConfig } from '$lib/modules/presi/module.config';
|
|||
import { inventoryModuleConfig } from '$lib/modules/inventory/module.config';
|
||||
import { photosModuleConfig } from '$lib/modules/photos/module.config';
|
||||
import { skilltreeModuleConfig } from '$lib/modules/skilltree/module.config';
|
||||
import { citycornersModuleConfig } from '$lib/modules/citycorners/module.config';
|
||||
import { timesModuleConfig } from '$lib/modules/times/module.config';
|
||||
import { questionsModuleConfig } from '$lib/modules/questions/module.config';
|
||||
import { foodModuleConfig } from '$lib/modules/food/module.config';
|
||||
import { plantsModuleConfig } from '$lib/modules/plants/module.config';
|
||||
import { uloadModuleConfig } from '$lib/modules/uload/module.config';
|
||||
import { calcModuleConfig } from '$lib/modules/calc/module.config';
|
||||
import { moodlitModuleConfig } from '$lib/modules/moodlit/module.config';
|
||||
import { memoroModuleConfig } from '$lib/modules/memoro/module.config';
|
||||
import { guidesModuleConfig } from '$lib/modules/guides/module.config';
|
||||
import { habitsModuleConfig } from '$lib/modules/habits/module.config';
|
||||
|
|
@ -101,7 +98,6 @@ import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
|
|||
import { broadcastModuleConfig } from '$lib/modules/broadcasts/module.config';
|
||||
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||
import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
||||
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
||||
import { writingModuleConfig } from '$lib/modules/writing/module.config';
|
||||
import { comicModuleConfig } from '$lib/modules/comic/module.config';
|
||||
import { augurModuleConfig } from '$lib/modules/augur/module.config';
|
||||
|
|
@ -125,14 +121,11 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
inventoryModuleConfig,
|
||||
photosModuleConfig,
|
||||
skilltreeModuleConfig,
|
||||
citycornersModuleConfig,
|
||||
timesModuleConfig,
|
||||
questionsModuleConfig,
|
||||
foodModuleConfig,
|
||||
plantsModuleConfig,
|
||||
uloadModuleConfig,
|
||||
calcModuleConfig,
|
||||
moodlitModuleConfig,
|
||||
memoroModuleConfig,
|
||||
guidesModuleConfig,
|
||||
habitsModuleConfig,
|
||||
|
|
@ -163,7 +156,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
broadcastModuleConfig,
|
||||
wetterModuleConfig,
|
||||
websiteModuleConfig,
|
||||
wardrobeModuleConfig,
|
||||
writingModuleConfig,
|
||||
comicModuleConfig,
|
||||
augurModuleConfig,
|
||||
|
|
|
|||
|
|
@ -139,18 +139,6 @@ const TABLES: TableConfig[] = [
|
|||
return recipesStore.setVisibility(id, next);
|
||||
},
|
||||
},
|
||||
{
|
||||
module: 'wardrobe',
|
||||
collection: 'wardrobeOutfits',
|
||||
moduleLabel: 'Wardrobe (Outfits)',
|
||||
encrypted: true,
|
||||
title: (r) => asString(r.name),
|
||||
href: () => '/wardrobe',
|
||||
setVisibility: async (id, next) => {
|
||||
const { wardrobeOutfitsStore } = await import('$lib/modules/wardrobe/stores/outfits.svelte');
|
||||
return wardrobeOutfitsStore.setVisibility(id, next);
|
||||
},
|
||||
},
|
||||
{
|
||||
module: 'comic',
|
||||
collection: 'comicStories',
|
||||
|
|
|
|||
|
|
@ -75,14 +75,6 @@ export function generateContextDocument(
|
|||
lines.push(`- Kaffee: ${day.drinks.coffee.count}x (${day.drinks.coffee.ml}ml)`);
|
||||
}
|
||||
|
||||
// Nutrition
|
||||
lines.push(
|
||||
`- Ernaehrung: ${day.nutrition.meals} Mahlzeiten, ${day.nutrition.calories.actual} / ${day.nutrition.calories.goal} kcal (${day.nutrition.calories.percent}%)`
|
||||
);
|
||||
if (day.nutrition.protein) {
|
||||
lines.push(` - Protein: ${day.nutrition.protein.actual}g / ${day.nutrition.protein.goal}g`);
|
||||
}
|
||||
|
||||
// Places
|
||||
if (day.places.visitedToday > 0) {
|
||||
lines.push(`- ${day.places.visitedToday} Orte besucht`);
|
||||
|
|
|
|||
|
|
@ -61,18 +61,6 @@ const METRICS: MetricDef[] = [
|
|||
label: 'Kaffee (Tassen)',
|
||||
extract: (days) => countByType(days, 'DrinkLogged', (p) => p.drinkType === 'coffee'),
|
||||
},
|
||||
{
|
||||
id: 'food:calories',
|
||||
module: 'food',
|
||||
label: 'Kalorien',
|
||||
extract: (days) => sumByTypeField(days, 'MealLogged', 'calories'),
|
||||
},
|
||||
{
|
||||
id: 'food:meals',
|
||||
module: 'food',
|
||||
label: 'Mahlzeiten',
|
||||
extract: (days) => countByType(days, 'MealLogged'),
|
||||
},
|
||||
{
|
||||
id: 'calendar:events',
|
||||
module: 'calendar',
|
||||
|
|
|
|||
|
|
@ -14,12 +14,10 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
|||
import { db } from '../database';
|
||||
import { decryptRecords } from '../crypto';
|
||||
import { DEFAULT_DAILY_GOAL_ML } from '$lib/modules/drink/types';
|
||||
import { DEFAULT_DAILY_VALUES } from '$lib/modules/food/constants';
|
||||
import { trackingStore } from '$lib/modules/places/stores/tracking.svelte';
|
||||
import type { LocalTask } from '$lib/modules/todo/types';
|
||||
import type { LocalEvent } from '$lib/modules/calendar/types';
|
||||
import type { LocalDrinkEntry } from '$lib/modules/drink/types';
|
||||
import type { LocalMeal, LocalGoal as NutriGoal } from '$lib/modules/food/types';
|
||||
import type { LocalPlace } from '$lib/modules/places/types';
|
||||
import type { LocalTimeBlock } from '../time-blocks/types';
|
||||
import type { DaySnapshot, TaskSummary, EventSummary } from './types';
|
||||
|
|
@ -38,11 +36,6 @@ function emptySnapshot(date: string): DaySnapshot {
|
|||
coffee: { ml: 0, count: 0 },
|
||||
total: { ml: 0, count: 0 },
|
||||
},
|
||||
nutrition: {
|
||||
meals: 0,
|
||||
calories: { actual: 0, goal: DEFAULT_DAILY_VALUES.calories, percent: 0 },
|
||||
protein: null,
|
||||
},
|
||||
places: { visitedToday: 0, tracking: false },
|
||||
};
|
||||
}
|
||||
|
|
@ -53,8 +46,8 @@ async function buildSnapshot(): Promise<DaySnapshot> {
|
|||
const todayStart = `${today}T00:00:00`;
|
||||
const todayEnd = `${today}T23:59:59`;
|
||||
|
||||
// ── Parallel queries — all 5 modules at once ────
|
||||
const [allTasks, blocks, allDrinks, allMeals, foodGoals, allPlaces] = await Promise.all([
|
||||
// ── Parallel queries — all modules at once ──────
|
||||
const [allTasks, blocks, allDrinks, allPlaces] = await Promise.all([
|
||||
db.table<LocalTask>('tasks').toArray(),
|
||||
db
|
||||
.table<LocalTimeBlock>('timeBlocks')
|
||||
|
|
@ -62,8 +55,6 @@ async function buildSnapshot(): Promise<DaySnapshot> {
|
|||
.between(todayStart, todayEnd + '\uffff')
|
||||
.toArray(),
|
||||
db.table<LocalDrinkEntry>('drinkEntries').toArray(),
|
||||
db.table<LocalMeal>('meals').toArray(),
|
||||
db.table<NutriGoal>('goals').toArray(),
|
||||
db.table<LocalPlace>('places').toArray(),
|
||||
]);
|
||||
|
||||
|
|
@ -73,13 +64,11 @@ async function buildSnapshot(): Promise<DaySnapshot> {
|
|||
(b) => !b.deletedAt && b.type === 'event' && b.sourceModule === 'calendar'
|
||||
);
|
||||
const todayDrinks = allDrinks.filter((d) => !d.deletedAt && d.date === today);
|
||||
const todayMeals = allMeals.filter((m) => !m.deletedAt && m.date === today);
|
||||
|
||||
const [decryptedTasks, decryptedBlocks, decryptedDrinks, decryptedMeals] = await Promise.all([
|
||||
const [decryptedTasks, decryptedBlocks, decryptedDrinks] = await Promise.all([
|
||||
decryptRecords<LocalTask>('tasks', activeTasks),
|
||||
decryptRecords<LocalTimeBlock>('timeBlocks', eventBlocks),
|
||||
decryptRecords<LocalDrinkEntry>('drinkEntries', todayDrinks),
|
||||
decryptRecords<LocalMeal>('meals', todayMeals),
|
||||
]);
|
||||
|
||||
// ── Tasks ───────────────────────────────────────
|
||||
|
|
@ -126,20 +115,6 @@ async function buildSnapshot(): Promise<DaySnapshot> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Nutrition ───────────────────────────────────
|
||||
let totalCalories = 0;
|
||||
let totalProtein = 0;
|
||||
for (const m of decryptedMeals) {
|
||||
const n = m.nutrition as { calories?: number; protein?: number } | null;
|
||||
if (n) {
|
||||
totalCalories += n.calories ?? 0;
|
||||
totalProtein += n.protein ?? 0;
|
||||
}
|
||||
}
|
||||
const activeGoal = foodGoals.find((g) => !g.deletedAt);
|
||||
const calorieGoal = activeGoal?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories;
|
||||
const proteinGoal = activeGoal?.dailyProtein;
|
||||
|
||||
// ── Places ──────────────────────────────────────
|
||||
const visitedToday = allPlaces.filter(
|
||||
(p) => !p.deletedAt && p.lastVisitedAt && (p.lastVisitedAt as string).startsWith(today)
|
||||
|
|
@ -167,15 +142,6 @@ async function buildSnapshot(): Promise<DaySnapshot> {
|
|||
coffee: { ml: coffeeMl, count: coffeeCount },
|
||||
total: { ml: totalMl, count: totalCount },
|
||||
},
|
||||
nutrition: {
|
||||
meals: decryptedMeals.length,
|
||||
calories: {
|
||||
actual: Math.round(totalCalories),
|
||||
goal: calorieGoal,
|
||||
percent: Math.min(Math.round((totalCalories / calorieGoal) * 100), 100),
|
||||
},
|
||||
protein: proteinGoal ? { actual: Math.round(totalProtein), goal: proteinGoal } : null,
|
||||
},
|
||||
places: {
|
||||
visitedToday,
|
||||
tracking: trackingStore.isTracking,
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ describe('Streak Tracker', () => {
|
|||
expect(state.currentStreak).toBe(1);
|
||||
});
|
||||
|
||||
it('tracks multiple streak types independently', async () => {
|
||||
it('tracks task streak on task completed event', async () => {
|
||||
startStreakTracker();
|
||||
|
||||
eventBus.emit({
|
||||
|
|
@ -147,30 +147,9 @@ describe('Streak Tracker', () => {
|
|||
actor: USER_ACTOR,
|
||||
},
|
||||
});
|
||||
eventBus.emit({
|
||||
type: 'MealLogged',
|
||||
payload: {
|
||||
mealId: '1',
|
||||
mealType: 'lunch',
|
||||
inputType: 'text',
|
||||
description: 'Pasta',
|
||||
date: todayStr(),
|
||||
},
|
||||
meta: {
|
||||
id: '2',
|
||||
timestamp: new Date().toISOString(),
|
||||
appId: 'food',
|
||||
collection: 'meals',
|
||||
recordId: '2',
|
||||
userId: 'u1',
|
||||
actor: USER_ACTOR,
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
|
||||
const tasks = await db.table(TABLE).get('streak-tasks-completed');
|
||||
const meals = await db.table(TABLE).get('streak-meals-logged');
|
||||
expect(tasks?.currentStreak).toBe(1);
|
||||
expect(meals?.currentStreak).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,12 +72,6 @@ const STREAK_DEFS: StreakDef[] = [
|
|||
label: 'Tasks erledigt',
|
||||
triggerEvents: ['TaskCompleted'],
|
||||
},
|
||||
{
|
||||
id: 'streak-meals-logged',
|
||||
moduleId: 'food',
|
||||
label: 'Mahlzeiten getrackt',
|
||||
triggerEvents: ['MealLogged', 'MealFromPhotoLogged'],
|
||||
},
|
||||
{
|
||||
id: 'streak-workout',
|
||||
moduleId: 'body',
|
||||
|
|
|
|||
|
|
@ -47,12 +47,6 @@ export interface DaySnapshot {
|
|||
total: { ml: number; count: number };
|
||||
};
|
||||
|
||||
nutrition: {
|
||||
meals: number;
|
||||
calories: { actual: number; goal: number; percent: number };
|
||||
protein: { actual: number; goal: number } | null;
|
||||
};
|
||||
|
||||
places: {
|
||||
visitedToday: number;
|
||||
tracking: boolean;
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export function scopedForModule<T, PK>(
|
|||
* space as the second step.
|
||||
*
|
||||
* const recent = await scopedAnd(
|
||||
* db.table<LocalMeal>('meals').where('date').aboveOrEqual(since),
|
||||
* db.table<LocalTask>('tasks').where('updatedAt').aboveOrEqual(since),
|
||||
* ).toArray();
|
||||
*
|
||||
* The wrapper accepts any Collection so the caller can freely build
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { HABITS_GUEST_SEED } from '$lib/modules/habits/collections';
|
|||
import { BODY_GUEST_SEED } from '$lib/modules/body/collections';
|
||||
import { JOURNAL_GUEST_SEED } from '$lib/modules/journal/collections';
|
||||
import { DREAMS_GUEST_SEED } from '$lib/modules/dreams/collections';
|
||||
import { MOODLIT_GUEST_SEED } from '$lib/modules/moodlit/collections';
|
||||
import { CONTACTS_GUEST_SEED } from '$lib/modules/contacts/collections';
|
||||
import { CALENDAR_GUEST_SEED } from '$lib/modules/calendar/collections';
|
||||
import { CHAT_GUEST_SEED } from '$lib/modules/chat/collections';
|
||||
|
|
@ -58,7 +57,6 @@ register(HABITS_GUEST_SEED);
|
|||
register(BODY_GUEST_SEED);
|
||||
register(JOURNAL_GUEST_SEED);
|
||||
register(DREAMS_GUEST_SEED);
|
||||
register(MOODLIT_GUEST_SEED);
|
||||
register(CONTACTS_GUEST_SEED);
|
||||
register(CALENDAR_GUEST_SEED);
|
||||
register(CHAT_GUEST_SEED);
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export type TimeBlockSourceModule =
|
|||
| 'places'
|
||||
| 'cards'
|
||||
| 'music'
|
||||
| 'moodlit'
|
||||
| 'presi';
|
||||
|
||||
// ─── Local Record Types (Dexie) ──────────────────────────
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { registerTools } from './registry';
|
|||
import { todoTools } from '$lib/modules/todo/tools';
|
||||
import { calendarTools } from '$lib/modules/calendar/tools';
|
||||
import { drinkTools } from '$lib/modules/drink/tools';
|
||||
import { foodTools } from '$lib/modules/food/tools';
|
||||
import { placesTools } from '$lib/modules/places/tools';
|
||||
import { habitsTools } from '$lib/modules/habits/tools';
|
||||
import { journalTools } from '$lib/modules/journal/tools';
|
||||
|
|
@ -58,7 +57,6 @@ export function initTools(): void {
|
|||
registerTools(todoTools);
|
||||
registerTools(calendarTools);
|
||||
registerTools(drinkTools);
|
||||
registerTools(foodTools);
|
||||
registerTools(placesTools);
|
||||
registerTools(habitsTools);
|
||||
registerTools(journalTools);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* places just happens to be the first consumer.
|
||||
*/
|
||||
|
||||
export type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other';
|
||||
export type PlaceCategory = 'home' | 'work' | 'shopping' | 'transit' | 'leisure' | 'other';
|
||||
|
||||
/**
|
||||
* Where to send geocoding requests:
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
"maerchenzauber_long_desc": "Erstelle personalisierte Kindergeschichten mit KI-generierten Illustrationen.",
|
||||
"cards_desc": "KI Lernkarten",
|
||||
"cards_long_desc": "Erstelle und lerne mit smarten Lernkarten und KI-gestützter Wiederholung.",
|
||||
"moodlit_desc": "Stimmungslicht-Steuerung",
|
||||
"moodlit_long_desc": "Steuere deine smarten Lichter basierend auf deiner Stimmung und Aktivität.",
|
||||
"mana_desc": "Zentrale Verwaltung",
|
||||
"mana_long_desc": "Verwalte alle deine Mana-Apps und Einstellungen an einem Ort.",
|
||||
"status_published": "Verfügbar",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
"maerchenzauber_long_desc": "Create personalized children's stories with AI-generated illustrations.",
|
||||
"cards_desc": "AI Flashcards",
|
||||
"cards_long_desc": "Create and study with smart flashcards and AI-powered spaced repetition.",
|
||||
"moodlit_desc": "Mood light control",
|
||||
"moodlit_long_desc": "Control your smart lights based on your mood and activity.",
|
||||
"mana_desc": "Central management",
|
||||
"mana_long_desc": "Manage all your Mana apps and settings in one place.",
|
||||
"status_published": "Available",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
"maerchenzauber_long_desc": "Crea historias personalizadas para niños con ilustraciones generadas por IA.",
|
||||
"cards_desc": "Flashcards IA",
|
||||
"cards_long_desc": "Crea y estudia con flashcards inteligentes y repetición espaciada con IA.",
|
||||
"moodlit_desc": "Control de luces ambientales",
|
||||
"moodlit_long_desc": "Controla tus luces inteligentes según tu estado de ánimo y actividades.",
|
||||
"mana_desc": "Gestión central",
|
||||
"mana_long_desc": "Gestiona todas tus apps Mana y configuraciones en un solo lugar.",
|
||||
"status_published": "Disponible",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
"maerchenzauber_long_desc": "Créez des histoires personnalisées pour enfants avec des illustrations générées par l'IA.",
|
||||
"cards_desc": "Flashcards IA",
|
||||
"cards_long_desc": "Créez et étudiez avec des flashcards intelligentes et la répétition espacée assistée par IA.",
|
||||
"moodlit_desc": "Contrôle d'éclairage ambiant",
|
||||
"moodlit_long_desc": "Contrôlez vos lumières intelligentes en fonction de votre humeur et de vos activités.",
|
||||
"mana_desc": "Gestion centrale",
|
||||
"mana_long_desc": "Gérez toutes vos applications Mana et paramètres en un seul endroit.",
|
||||
"status_published": "Disponible",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
"maerchenzauber_long_desc": "Crea storie personalizzate per bambini con illustrazioni generate dall'AI.",
|
||||
"cards_desc": "Flashcard AI",
|
||||
"cards_long_desc": "Crea e studia con flashcard intelligenti e ripetizione spaziata basata su AI.",
|
||||
"moodlit_desc": "Controllo luci ambientali",
|
||||
"moodlit_long_desc": "Controlla le tue luci smart in base al tuo umore e alle tue attività.",
|
||||
"mana_desc": "Gestione centrale",
|
||||
"mana_long_desc": "Gestisci tutte le tue app Mana e impostazioni in un unico posto.",
|
||||
"status_published": "Disponibile",
|
||||
|
|
|
|||
|
|
@ -15,15 +15,12 @@
|
|||
"music": "Musik",
|
||||
"photos": "Fotos",
|
||||
"storage": "Speicher",
|
||||
"food": "Essen",
|
||||
"plants": "Pflanzen",
|
||||
"presi": "Presi",
|
||||
"inventory": "Inventar",
|
||||
"memoro": "Memoro",
|
||||
"questions": "Recherche",
|
||||
"skilltree": "Skills",
|
||||
"moodlit": "Moodlit",
|
||||
"citycorners": "Stadtführer",
|
||||
"uload": "uLoad",
|
||||
"calc": "Rechner",
|
||||
"period": "Periode",
|
||||
|
|
@ -70,7 +67,6 @@
|
|||
"help": "Hilfe",
|
||||
"wetter": "Wetter",
|
||||
"feedback": "Feedback",
|
||||
"wardrobe": "Kleiderschrank",
|
||||
"library": "Bibliothek",
|
||||
"spaces": "Bereiche",
|
||||
"website": "Website",
|
||||
|
|
|
|||
|
|
@ -15,15 +15,12 @@
|
|||
"music": "Music",
|
||||
"photos": "Photos",
|
||||
"storage": "Storage",
|
||||
"food": "Food",
|
||||
"plants": "Plants",
|
||||
"presi": "Presi",
|
||||
"inventory": "Inventory",
|
||||
"memoro": "Memoro",
|
||||
"questions": "Research",
|
||||
"skilltree": "Skills",
|
||||
"moodlit": "Moodlit",
|
||||
"citycorners": "City Guide",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculator",
|
||||
"period": "Period",
|
||||
|
|
@ -70,7 +67,6 @@
|
|||
"help": "Help",
|
||||
"wetter": "Weather",
|
||||
"feedback": "Feedback",
|
||||
"wardrobe": "Wardrobe",
|
||||
"library": "Library",
|
||||
"spaces": "Spaces",
|
||||
"website": "Website",
|
||||
|
|
|
|||
|
|
@ -15,15 +15,12 @@
|
|||
"music": "Música",
|
||||
"photos": "Fotos",
|
||||
"storage": "Almacén",
|
||||
"food": "Food",
|
||||
"plants": "Plantas",
|
||||
"presi": "Presi",
|
||||
"inventory": "Inventario",
|
||||
"memoro": "Memoro",
|
||||
"questions": "Investigación",
|
||||
"skilltree": "Skills",
|
||||
"moodlit": "Moodlit",
|
||||
"citycorners": "Guía urbana",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculadora",
|
||||
"period": "Ciclo",
|
||||
|
|
@ -70,7 +67,6 @@
|
|||
"help": "Ayuda",
|
||||
"wetter": "Tiempo",
|
||||
"feedback": "Comentarios",
|
||||
"wardrobe": "Armario",
|
||||
"library": "Biblioteca",
|
||||
"spaces": "Espacios",
|
||||
"website": "Sitio web",
|
||||
|
|
|
|||
|
|
@ -15,15 +15,12 @@
|
|||
"music": "Musique",
|
||||
"photos": "Photos",
|
||||
"storage": "Stockage",
|
||||
"food": "Food",
|
||||
"plants": "Plantes",
|
||||
"presi": "Presi",
|
||||
"inventory": "Inventaire",
|
||||
"memoro": "Memoro",
|
||||
"questions": "Recherche",
|
||||
"skilltree": "Skills",
|
||||
"moodlit": "Moodlit",
|
||||
"citycorners": "Guide urbain",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculatrice",
|
||||
"period": "Règles",
|
||||
|
|
@ -70,7 +67,6 @@
|
|||
"help": "Aide",
|
||||
"wetter": "Météo",
|
||||
"feedback": "Retours",
|
||||
"wardrobe": "Garde-robe",
|
||||
"library": "Bibliothèque",
|
||||
"spaces": "Espaces",
|
||||
"website": "Site web",
|
||||
|
|
|
|||
|
|
@ -15,15 +15,12 @@
|
|||
"music": "Musica",
|
||||
"photos": "Foto",
|
||||
"storage": "Archivio",
|
||||
"food": "Food",
|
||||
"plants": "Piante",
|
||||
"presi": "Presi",
|
||||
"inventory": "Inventario",
|
||||
"memoro": "Memoro",
|
||||
"questions": "Ricerca",
|
||||
"skilltree": "Skills",
|
||||
"moodlit": "Moodlit",
|
||||
"citycorners": "Guida città",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calcolatrice",
|
||||
"period": "Ciclo",
|
||||
|
|
@ -70,7 +67,6 @@
|
|||
"help": "Aiuto",
|
||||
"wetter": "Meteo",
|
||||
"feedback": "Feedback",
|
||||
"wardrobe": "Guardaroba",
|
||||
"library": "Biblioteca",
|
||||
"spaces": "Spazi",
|
||||
"website": "Sito web",
|
||||
|
|
|
|||
|
|
@ -1,255 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "CityCorners",
|
||||
"tagline": "Entdecke Städte weltweit"
|
||||
},
|
||||
"nav": {
|
||||
"explore": "Entdecken",
|
||||
"map": "Karte",
|
||||
"add": "Hinzufügen",
|
||||
"favorites": "Favoriten",
|
||||
"settings": "Einstellungen",
|
||||
"showNav": "Navigation einblenden",
|
||||
"hideNav": "Navigation ausblenden",
|
||||
"cities": "Städte"
|
||||
},
|
||||
"cities": {
|
||||
"title": "Städte entdecken",
|
||||
"subtitle": "Von der Community für die Community",
|
||||
"search": "Stadt suchen...",
|
||||
"add": "Stadt hinzufügen",
|
||||
"empty": "Noch keine Städte. Sei der Erste!",
|
||||
"locationsCount": "{count} Orte",
|
||||
"noLocationsYet": "Noch keine Orte",
|
||||
"contributors": "{count} Beitragende",
|
||||
"contributorsOne": "1 Beitragender",
|
||||
"onMap": "{count} auf der Karte",
|
||||
"recentlyAdded": "Zuletzt hinzugefügt",
|
||||
"stats": "Statistiken",
|
||||
"totalCities": "{count} Städte",
|
||||
"totalLocations": "{count} Orte",
|
||||
"totalContributors": "{count} Beitragende",
|
||||
"topCategories": "Top-Kategorien"
|
||||
},
|
||||
"cityAdd": {
|
||||
"title": "Neue Stadt anlegen",
|
||||
"subtitle": "Füge eine Stadt, ein Dorf oder einen Ort hinzu",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "z.B. Konstanz",
|
||||
"country": "Land",
|
||||
"countryPlaceholder": "z.B. Deutschland",
|
||||
"state": "Bundesland / Region (optional)",
|
||||
"statePlaceholder": "z.B. Baden-Württemberg",
|
||||
"description": "Beschreibung (optional)",
|
||||
"descriptionPlaceholder": "Was macht diesen Ort besonders?",
|
||||
"imageUrl": "Bild-URL (optional)",
|
||||
"imageUrlPlaceholder": "https://example.com/bild.jpg",
|
||||
"submit": "Stadt anlegen",
|
||||
"submitting": "Wird angelegt...",
|
||||
"loginRequired": "Melde dich an, um Städte anzulegen.",
|
||||
"error": "Fehler beim Anlegen. Bitte versuche es erneut.",
|
||||
"geocoding": "Koordinaten werden ermittelt...",
|
||||
"coordinatesFound": "Koordinaten gefunden",
|
||||
"slugExists": "Eine Stadt mit diesem Namen existiert bereits."
|
||||
},
|
||||
"home": {
|
||||
"title": "Orte entdecken",
|
||||
"subtitle": "Sehenswürdigkeiten, Restaurants, Museen und mehr",
|
||||
"all": "Alle",
|
||||
"loading": "Laden...",
|
||||
"noResults": "Keine Orte gefunden.",
|
||||
"noResultsCategory": "Keine {category} gefunden.",
|
||||
"addFirst": "Ersten Ort hinzufügen",
|
||||
"loadMore": "Mehr laden"
|
||||
},
|
||||
"categories": {
|
||||
"sight": "Sehenswürdigkeiten",
|
||||
"restaurant": "Restaurants",
|
||||
"shop": "Läden",
|
||||
"museum": "Museen",
|
||||
"cafe": "Cafés",
|
||||
"bar": "Bars",
|
||||
"park": "Parks",
|
||||
"beach": "Strandbäder",
|
||||
"hotel": "Hotels",
|
||||
"event_venue": "Veranstaltungsorte",
|
||||
"viewpoint": "Aussichtspunkte"
|
||||
},
|
||||
"category": {
|
||||
"sight": "Sehenswürdigkeit",
|
||||
"restaurant": "Restaurant",
|
||||
"shop": "Laden",
|
||||
"museum": "Museum",
|
||||
"cafe": "Café",
|
||||
"bar": "Bar",
|
||||
"park": "Park",
|
||||
"beach": "Strandbad",
|
||||
"hotel": "Hotel",
|
||||
"event_venue": "Veranstaltungsort",
|
||||
"viewpoint": "Aussichtspunkt"
|
||||
},
|
||||
"detail": {
|
||||
"history": "Geschichte",
|
||||
"openInMaps": "OSM",
|
||||
"showOnMap": "Auf Karte",
|
||||
"directions": "Route",
|
||||
"share": "Teilen",
|
||||
"linkCopied": "Link kopiert!",
|
||||
"showDetails": "Details",
|
||||
"back": "Zurück zur Übersicht",
|
||||
"notFound": "Ort nicht gefunden.",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirm": "Bist du sicher, dass du diesen Ort löschen möchtest? Das kann nicht rückgängig gemacht werden.",
|
||||
"confirmDelete": "Endgültig löschen",
|
||||
"deleting": "Wird gelöscht...",
|
||||
"cancel": "Abbrechen",
|
||||
"nearby": "In der Nähe",
|
||||
"website": "Webseite",
|
||||
"phone": "Telefon",
|
||||
"openingHours": "Öffnungszeiten",
|
||||
"closed": "Geschlossen",
|
||||
"openNow": "Jetzt geöffnet",
|
||||
"closedNow": "Geschlossen"
|
||||
},
|
||||
"days": {
|
||||
"mo": "Montag",
|
||||
"tu": "Dienstag",
|
||||
"we": "Mittwoch",
|
||||
"th": "Donnerstag",
|
||||
"fr": "Freitag",
|
||||
"sa": "Samstag",
|
||||
"su": "Sonntag"
|
||||
},
|
||||
"gallery": {
|
||||
"addPhoto": "Foto hinzufügen",
|
||||
"add": "Hinzufügen",
|
||||
"addError": "Foto konnte nicht hinzugefügt werden."
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favoriten",
|
||||
"subtitle": "Deine gespeicherten Orte",
|
||||
"empty": "Noch keine Favoriten. Tippe auf das Herz bei einer Location, um sie zu speichern.",
|
||||
"loginRequired": "Melde dich an, um Favoriten zu speichern.",
|
||||
"add": "Zu Favoriten hinzufügen",
|
||||
"remove": "Aus Favoriten entfernen",
|
||||
"tabFavorites": "Favoriten",
|
||||
"tabCollections": "Sammlungen"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Sammlungen",
|
||||
"empty": "Noch keine Sammlungen erstellt.",
|
||||
"create": "Sammlung erstellen",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "z.B. Meine Lieblingsrestaurants",
|
||||
"description": "Beschreibung (optional)",
|
||||
"descriptionPlaceholder": "Worum geht es in dieser Sammlung?",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"locations": "{count} Orte",
|
||||
"noLocations": "Keine Orte in dieser Sammlung.",
|
||||
"delete": "Sammlung löschen",
|
||||
"deleteConfirm": "Bist du sicher, dass du diese Sammlung löschen möchtest?",
|
||||
"back": "Zurück zu Sammlungen"
|
||||
},
|
||||
"map": {
|
||||
"title": "Karte",
|
||||
"subtitle": "Alle Orte auf der Karte",
|
||||
"locateMe": "Mein Standort",
|
||||
"yourLocation": "Du bist hier",
|
||||
"geolocationNotSupported": "Standortbestimmung wird nicht unterstützt.",
|
||||
"geolocationError": "Standort konnte nicht ermittelt werden.",
|
||||
"filterAll": "Alle"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Ort suchen...",
|
||||
"noResults": "Keine Ergebnisse",
|
||||
"searching": "Suche..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"appearance": "Erscheinungsbild",
|
||||
"mode": "Modus",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"system": "System",
|
||||
"colorScheme": "Farbschema",
|
||||
"account": "Account",
|
||||
"email": "E-Mail",
|
||||
"logout": "Abmelden",
|
||||
"loginPrompt": "Melde dich an, um Favoriten zu speichern und alle Features zu nutzen.",
|
||||
"login": "Anmelden",
|
||||
"register": "Registrieren",
|
||||
"about": "Über CityCorners",
|
||||
"aboutText": "CityCorners ist eine offene Plattform für Stadtführer weltweit. Entdecke Orte, die von der Community geteilt werden — oder lege selbst eine Stadt an."
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Login - CityCorners",
|
||||
"registerTitle": "Registrieren - CityCorners"
|
||||
},
|
||||
"add": {
|
||||
"title": "Ort hinzufügen",
|
||||
"subtitle": "Teile deinen Lieblingsort",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "z.B. Café am See",
|
||||
"category": "Kategorie",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Was macht diesen Ort besonders?",
|
||||
"minChars": "Mindestens 10 Zeichen",
|
||||
"address": "Adresse (optional)",
|
||||
"addressPlaceholder": "z.B. Seestraße 1",
|
||||
"searchTitle": "Ort im Web suchen",
|
||||
"searchSubtitle": "Wir suchen automatisch nach Infos und füllen das Formular vor.",
|
||||
"searchPlaceholder": "z.B. Café Zeitlos",
|
||||
"searchButton": "Suchen",
|
||||
"skipSearch": "Überspringen und manuell eintragen",
|
||||
"foundSources": "Quellen gefunden:",
|
||||
"reset": "Zurück",
|
||||
"submit": "Ort einreichen",
|
||||
"submitting": "Wird eingereicht...",
|
||||
"loginRequired": "Melde dich an, um Orte hinzuzufügen.",
|
||||
"error": "Fehler beim Einreichen. Bitte versuche es erneut.",
|
||||
"imageUrl": "Bild-URL (optional)",
|
||||
"imageUrlPlaceholder": "https://example.com/bild.jpg",
|
||||
"imagePreview": "Bildvorschau",
|
||||
"imageLoadError": "Bild konnte nicht geladen werden.",
|
||||
"imageRetry": "Erneut versuchen",
|
||||
"geocoding": "Koordinaten werden ermittelt...",
|
||||
"coordinatesFound": "Koordinaten gefunden",
|
||||
"website": "Webseite (optional)",
|
||||
"websitePlaceholder": "https://example.com",
|
||||
"phone": "Telefon (optional)",
|
||||
"phonePlaceholder": "+49 7531 12345"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Ort bearbeiten",
|
||||
"subtitle": "Ändere die Details dieses Ortes",
|
||||
"save": "Änderungen speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"cancel": "Abbrechen",
|
||||
"error": "Fehler beim Speichern. Bitte versuche es erneut.",
|
||||
"loadError": "Ort konnte nicht geladen werden.",
|
||||
"forbidden": "Du kannst nur deine eigenen Orte bearbeiten."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Bewertungen",
|
||||
"write": "Bewertung schreiben",
|
||||
"yourRating": "Deine Bewertung",
|
||||
"commentPlaceholder": "Was hat dir gefallen? (optional)",
|
||||
"submit": "Absenden",
|
||||
"submitting": "Wird gesendet...",
|
||||
"loginRequired": "Melde dich an, um eine Bewertung zu schreiben.",
|
||||
"alreadyReviewed": "Du hast diesen Ort bereits bewertet.",
|
||||
"deleteConfirm": "Bewertung löschen?",
|
||||
"delete": "Löschen",
|
||||
"noReviews": "Noch keine Bewertungen. Sei der Erste!",
|
||||
"error": "Bewertung konnte nicht gespeichert werden.",
|
||||
"count": "{count} Bewertungen",
|
||||
"countOne": "1 Bewertung"
|
||||
},
|
||||
"offline": {
|
||||
"title": "Keine Verbindung",
|
||||
"message": "Du bist gerade offline. Sobald du wieder eine Internetverbindung hast, kannst du CityCorners weiter nutzen.",
|
||||
"retry": "Erneut versuchen"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "CityCorners",
|
||||
"tagline": "Discover cities worldwide"
|
||||
},
|
||||
"nav": {
|
||||
"explore": "Explore",
|
||||
"map": "Map",
|
||||
"add": "Add",
|
||||
"favorites": "Favorites",
|
||||
"settings": "Settings",
|
||||
"showNav": "Show navigation",
|
||||
"hideNav": "Hide navigation",
|
||||
"cities": "Cities"
|
||||
},
|
||||
"cities": {
|
||||
"title": "Discover cities",
|
||||
"subtitle": "By the community, for the community",
|
||||
"search": "Search cities...",
|
||||
"add": "Add a city",
|
||||
"empty": "No cities yet. Be the first!",
|
||||
"locationsCount": "{count} places",
|
||||
"noLocationsYet": "No places yet",
|
||||
"contributors": "{count} contributors",
|
||||
"contributorsOne": "1 contributor",
|
||||
"onMap": "{count} on the map",
|
||||
"recentlyAdded": "Recently added",
|
||||
"stats": "Statistics",
|
||||
"totalCities": "{count} cities",
|
||||
"totalLocations": "{count} places",
|
||||
"totalContributors": "{count} contributors",
|
||||
"topCategories": "Top categories"
|
||||
},
|
||||
"cityAdd": {
|
||||
"title": "Add a new city",
|
||||
"subtitle": "Add a city, village, or town",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. Berlin",
|
||||
"country": "Country",
|
||||
"countryPlaceholder": "e.g. Germany",
|
||||
"state": "State / Region (optional)",
|
||||
"statePlaceholder": "e.g. Bavaria",
|
||||
"description": "Description (optional)",
|
||||
"descriptionPlaceholder": "What makes this place special?",
|
||||
"imageUrl": "Image URL (optional)",
|
||||
"imageUrlPlaceholder": "https://example.com/image.jpg",
|
||||
"submit": "Add city",
|
||||
"submitting": "Creating...",
|
||||
"loginRequired": "Sign in to add cities.",
|
||||
"error": "Failed to create. Please try again.",
|
||||
"geocoding": "Finding coordinates...",
|
||||
"coordinatesFound": "Coordinates found",
|
||||
"slugExists": "A city with this name already exists."
|
||||
},
|
||||
"home": {
|
||||
"title": "Discover places",
|
||||
"subtitle": "Sights, restaurants, museums and more",
|
||||
"all": "All",
|
||||
"loading": "Loading...",
|
||||
"noResults": "No places found.",
|
||||
"noResultsCategory": "No {category} found.",
|
||||
"addFirst": "Add the first place",
|
||||
"loadMore": "Load more"
|
||||
},
|
||||
"categories": {
|
||||
"sight": "Sights",
|
||||
"restaurant": "Restaurants",
|
||||
"shop": "Shops",
|
||||
"museum": "Museums",
|
||||
"cafe": "Cafés",
|
||||
"bar": "Bars",
|
||||
"park": "Parks",
|
||||
"beach": "Beaches",
|
||||
"hotel": "Hotels",
|
||||
"event_venue": "Event Venues",
|
||||
"viewpoint": "Viewpoints"
|
||||
},
|
||||
"category": {
|
||||
"sight": "Sight",
|
||||
"restaurant": "Restaurant",
|
||||
"shop": "Shop",
|
||||
"museum": "Museum",
|
||||
"cafe": "Café",
|
||||
"bar": "Bar",
|
||||
"park": "Park",
|
||||
"beach": "Beach",
|
||||
"hotel": "Hotel",
|
||||
"event_venue": "Event Venue",
|
||||
"viewpoint": "Viewpoint"
|
||||
},
|
||||
"detail": {
|
||||
"history": "History",
|
||||
"openInMaps": "OSM",
|
||||
"showOnMap": "On map",
|
||||
"directions": "Directions",
|
||||
"share": "Share",
|
||||
"linkCopied": "Link copied!",
|
||||
"showDetails": "Details",
|
||||
"back": "Back to overview",
|
||||
"notFound": "Place not found.",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Are you sure you want to delete this place? This cannot be undone.",
|
||||
"confirmDelete": "Delete permanently",
|
||||
"deleting": "Deleting...",
|
||||
"cancel": "Cancel",
|
||||
"nearby": "Nearby",
|
||||
"website": "Website",
|
||||
"phone": "Phone",
|
||||
"openingHours": "Opening hours",
|
||||
"closed": "Closed",
|
||||
"openNow": "Open now",
|
||||
"closedNow": "Closed"
|
||||
},
|
||||
"days": {
|
||||
"mo": "Monday",
|
||||
"tu": "Tuesday",
|
||||
"we": "Wednesday",
|
||||
"th": "Thursday",
|
||||
"fr": "Friday",
|
||||
"sa": "Saturday",
|
||||
"su": "Sunday"
|
||||
},
|
||||
"gallery": {
|
||||
"addPhoto": "Add photo",
|
||||
"add": "Add",
|
||||
"addError": "Could not add photo."
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favorites",
|
||||
"subtitle": "Your saved places",
|
||||
"empty": "No favorites yet. Tap the heart on a location to save it.",
|
||||
"loginRequired": "Sign in to save favorites.",
|
||||
"add": "Add to favorites",
|
||||
"remove": "Remove from favorites",
|
||||
"tabFavorites": "Favorites",
|
||||
"tabCollections": "Collections"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"empty": "No collections created yet.",
|
||||
"create": "Create collection",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. My favorite restaurants",
|
||||
"description": "Description (optional)",
|
||||
"descriptionPlaceholder": "What is this collection about?",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"locations": "{count} places",
|
||||
"noLocations": "No places in this collection.",
|
||||
"delete": "Delete collection",
|
||||
"deleteConfirm": "Are you sure you want to delete this collection?",
|
||||
"back": "Back to collections"
|
||||
},
|
||||
"map": {
|
||||
"title": "Map",
|
||||
"subtitle": "All places on the map",
|
||||
"locateMe": "My location",
|
||||
"yourLocation": "You are here",
|
||||
"geolocationNotSupported": "Geolocation is not supported.",
|
||||
"geolocationError": "Could not determine location.",
|
||||
"filterAll": "All"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search places...",
|
||||
"noResults": "No results",
|
||||
"searching": "Searching..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"appearance": "Appearance",
|
||||
"mode": "Mode",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"colorScheme": "Color scheme",
|
||||
"account": "Account",
|
||||
"email": "Email",
|
||||
"logout": "Sign out",
|
||||
"loginPrompt": "Sign in to save favorites and use all features.",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"about": "About CityCorners",
|
||||
"aboutText": "CityCorners is an open platform for city guides worldwide. Discover places shared by the community — or add your own city."
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Login - CityCorners",
|
||||
"registerTitle": "Sign up - CityCorners"
|
||||
},
|
||||
"add": {
|
||||
"title": "Add a place",
|
||||
"subtitle": "Share your favorite spot",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. Lakeside Cafe",
|
||||
"category": "Category",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What makes this place special?",
|
||||
"minChars": "At least 10 characters",
|
||||
"address": "Address (optional)",
|
||||
"addressPlaceholder": "e.g. Main Street 1",
|
||||
"searchTitle": "Search for a place online",
|
||||
"searchSubtitle": "We'll automatically find info and pre-fill the form for you.",
|
||||
"searchPlaceholder": "e.g. Cafe Zeitlos",
|
||||
"searchButton": "Search",
|
||||
"skipSearch": "Skip and enter manually",
|
||||
"foundSources": "Sources found:",
|
||||
"reset": "Back",
|
||||
"submit": "Submit place",
|
||||
"submitting": "Submitting...",
|
||||
"loginRequired": "Sign in to add places.",
|
||||
"error": "Failed to submit. Please try again.",
|
||||
"imageUrl": "Image URL (optional)",
|
||||
"imageUrlPlaceholder": "https://example.com/image.jpg",
|
||||
"imagePreview": "Image preview",
|
||||
"imageLoadError": "Image could not be loaded.",
|
||||
"imageRetry": "Retry",
|
||||
"geocoding": "Finding coordinates...",
|
||||
"coordinatesFound": "Coordinates found",
|
||||
"website": "Website (optional)",
|
||||
"websitePlaceholder": "https://example.com",
|
||||
"phone": "Phone (optional)",
|
||||
"phonePlaceholder": "+49 7531 12345"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit place",
|
||||
"subtitle": "Update the details of this place",
|
||||
"save": "Save changes",
|
||||
"saving": "Saving...",
|
||||
"cancel": "Cancel",
|
||||
"error": "Failed to save. Please try again.",
|
||||
"loadError": "Could not load place.",
|
||||
"forbidden": "You can only edit your own places."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Reviews",
|
||||
"write": "Write a review",
|
||||
"yourRating": "Your rating",
|
||||
"commentPlaceholder": "What did you like? (optional)",
|
||||
"submit": "Submit",
|
||||
"submitting": "Submitting...",
|
||||
"loginRequired": "Sign in to write a review.",
|
||||
"alreadyReviewed": "You have already reviewed this place.",
|
||||
"deleteConfirm": "Delete review?",
|
||||
"delete": "Delete",
|
||||
"noReviews": "No reviews yet. Be the first!",
|
||||
"error": "Could not save review.",
|
||||
"count": "{count} reviews",
|
||||
"countOne": "1 review"
|
||||
},
|
||||
"offline": {
|
||||
"title": "No connection",
|
||||
"message": "You are currently offline. You can continue using CityCorners once you have an internet connection again.",
|
||||
"retry": "Try again"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "CityCorners",
|
||||
"tagline": "Descubre ciudades en todo el mundo"
|
||||
},
|
||||
"nav": {
|
||||
"explore": "Explorar",
|
||||
"map": "Mapa",
|
||||
"add": "Agregar",
|
||||
"favorites": "Favoritos",
|
||||
"settings": "Ajustes",
|
||||
"showNav": "Mostrar navegación",
|
||||
"hideNav": "Ocultar navegación",
|
||||
"cities": "Ciudades"
|
||||
},
|
||||
"cities": {
|
||||
"title": "Descubrir ciudades",
|
||||
"subtitle": "De la comunidad, para la comunidad",
|
||||
"search": "Buscar ciudades...",
|
||||
"add": "Agregar ciudad",
|
||||
"empty": "Aún no hay ciudades. ¡Sé el primero!",
|
||||
"locationsCount": "{count} lugares",
|
||||
"noLocationsYet": "Aún no hay lugares",
|
||||
"contributors": "{count} colaboradores",
|
||||
"contributorsOne": "1 colaborador",
|
||||
"onMap": "{count} en el mapa",
|
||||
"recentlyAdded": "Agregados recientemente",
|
||||
"stats": "Estadísticas",
|
||||
"totalCities": "{count} ciudades",
|
||||
"totalLocations": "{count} lugares",
|
||||
"totalContributors": "{count} colaboradores",
|
||||
"topCategories": "Categorías principales"
|
||||
},
|
||||
"cityAdd": {
|
||||
"title": "Agregar nueva ciudad",
|
||||
"subtitle": "Agrega una ciudad, pueblo o localidad",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "ej. Madrid",
|
||||
"country": "País",
|
||||
"countryPlaceholder": "ej. España",
|
||||
"state": "Comunidad / Región (opcional)",
|
||||
"statePlaceholder": "ej. Comunidad de Madrid",
|
||||
"description": "Descripción (opcional)",
|
||||
"descriptionPlaceholder": "¿Qué hace especial a este lugar?",
|
||||
"imageUrl": "URL de imagen (opcional)",
|
||||
"imageUrlPlaceholder": "https://example.com/imagen.jpg",
|
||||
"submit": "Agregar ciudad",
|
||||
"submitting": "Creando...",
|
||||
"loginRequired": "Inicia sesión para agregar ciudades.",
|
||||
"error": "Error al crear. Inténtalo de nuevo.",
|
||||
"geocoding": "Buscando coordenadas...",
|
||||
"coordinatesFound": "Coordenadas encontradas",
|
||||
"slugExists": "Ya existe una ciudad con este nombre."
|
||||
},
|
||||
"home": {
|
||||
"title": "Descubrir lugares",
|
||||
"subtitle": "Monumentos, restaurantes, museos y más",
|
||||
"all": "Todos",
|
||||
"loading": "Cargando...",
|
||||
"noResults": "No se encontraron lugares.",
|
||||
"noResultsCategory": "No se encontraron {category}.",
|
||||
"addFirst": "Agrega el primer lugar",
|
||||
"loadMore": "Cargar más"
|
||||
},
|
||||
"categories": {
|
||||
"sight": "Monumentos",
|
||||
"restaurant": "Restaurantes",
|
||||
"shop": "Tiendas",
|
||||
"museum": "Museos",
|
||||
"cafe": "Cafeterías",
|
||||
"bar": "Bares",
|
||||
"park": "Parques",
|
||||
"beach": "Playas",
|
||||
"hotel": "Hoteles",
|
||||
"event_venue": "Salas de eventos",
|
||||
"viewpoint": "Miradores"
|
||||
},
|
||||
"category": {
|
||||
"sight": "Monumento",
|
||||
"restaurant": "Restaurante",
|
||||
"shop": "Tienda",
|
||||
"museum": "Museo",
|
||||
"cafe": "Cafetería",
|
||||
"bar": "Bar",
|
||||
"park": "Parque",
|
||||
"beach": "Playa",
|
||||
"hotel": "Hotel",
|
||||
"event_venue": "Sala de eventos",
|
||||
"viewpoint": "Mirador"
|
||||
},
|
||||
"detail": {
|
||||
"history": "Historia",
|
||||
"openInMaps": "OSM",
|
||||
"showOnMap": "En el mapa",
|
||||
"directions": "Cómo llegar",
|
||||
"share": "Compartir",
|
||||
"linkCopied": "¡Enlace copiado!",
|
||||
"showDetails": "Detalles",
|
||||
"back": "Volver al listado",
|
||||
"notFound": "Lugar no encontrado.",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"deleteConfirm": "¿Seguro que quieres eliminar este lugar? No se puede deshacer.",
|
||||
"confirmDelete": "Eliminar definitivamente",
|
||||
"deleting": "Eliminando...",
|
||||
"cancel": "Cancelar",
|
||||
"nearby": "Cerca",
|
||||
"website": "Sitio web",
|
||||
"phone": "Teléfono",
|
||||
"openingHours": "Horario",
|
||||
"closed": "Cerrado",
|
||||
"openNow": "Abierto ahora",
|
||||
"closedNow": "Cerrado"
|
||||
},
|
||||
"days": {
|
||||
"mo": "Lunes",
|
||||
"tu": "Martes",
|
||||
"we": "Miércoles",
|
||||
"th": "Jueves",
|
||||
"fr": "Viernes",
|
||||
"sa": "Sábado",
|
||||
"su": "Domingo"
|
||||
},
|
||||
"gallery": {
|
||||
"addPhoto": "Agregar foto",
|
||||
"add": "Agregar",
|
||||
"addError": "No se pudo agregar la foto."
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favoritos",
|
||||
"subtitle": "Tus lugares guardados",
|
||||
"empty": "Aún no hay favoritos. Toca el corazón en un lugar para guardarlo.",
|
||||
"loginRequired": "Inicia sesión para guardar favoritos.",
|
||||
"add": "Agregar a favoritos",
|
||||
"remove": "Quitar de favoritos",
|
||||
"tabFavorites": "Favoritos",
|
||||
"tabCollections": "Colecciones"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Colecciones",
|
||||
"empty": "Aún no hay colecciones.",
|
||||
"create": "Crear colección",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "ej. Mis restaurantes favoritos",
|
||||
"description": "Descripción (opcional)",
|
||||
"descriptionPlaceholder": "¿De qué trata esta colección?",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"locations": "{count} lugares",
|
||||
"noLocations": "No hay lugares en esta colección.",
|
||||
"delete": "Eliminar colección",
|
||||
"deleteConfirm": "¿Seguro que quieres eliminar esta colección?",
|
||||
"back": "Volver a colecciones"
|
||||
},
|
||||
"map": {
|
||||
"title": "Mapa",
|
||||
"subtitle": "Todos los lugares en el mapa",
|
||||
"locateMe": "Mi ubicación",
|
||||
"yourLocation": "Estás aquí",
|
||||
"geolocationNotSupported": "La geolocalización no está disponible.",
|
||||
"geolocationError": "No se pudo determinar la ubicación.",
|
||||
"filterAll": "Todos"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar lugares...",
|
||||
"noResults": "Sin resultados",
|
||||
"searching": "Buscando..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
"appearance": "Apariencia",
|
||||
"mode": "Modo",
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"system": "Sistema",
|
||||
"colorScheme": "Esquema de color",
|
||||
"account": "Cuenta",
|
||||
"email": "Email",
|
||||
"logout": "Cerrar sesión",
|
||||
"loginPrompt": "Inicia sesión para guardar favoritos y usar todas las funciones.",
|
||||
"login": "Iniciar sesión",
|
||||
"register": "Registrarse",
|
||||
"about": "Sobre CityCorners",
|
||||
"aboutText": "CityCorners es una plataforma abierta para guías de ciudades en todo el mundo. Descubre lugares compartidos por la comunidad o agrega tu propia ciudad."
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Login - CityCorners",
|
||||
"registerTitle": "Registro - CityCorners"
|
||||
},
|
||||
"add": {
|
||||
"title": "Agregar un lugar",
|
||||
"subtitle": "Comparte tu sitio favorito",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "ej. Café del Lago",
|
||||
"category": "Categoría",
|
||||
"description": "Descripción",
|
||||
"descriptionPlaceholder": "¿Qué hace especial a este lugar?",
|
||||
"minChars": "Mínimo 10 caracteres",
|
||||
"address": "Dirección (opcional)",
|
||||
"addressPlaceholder": "ej. Calle Mayor 1",
|
||||
"searchTitle": "Buscar un lugar en línea",
|
||||
"searchSubtitle": "Encontraremos la información y rellenaremos el formulario automáticamente.",
|
||||
"searchPlaceholder": "ej. Café Zeitlos",
|
||||
"searchButton": "Buscar",
|
||||
"skipSearch": "Omitir e ingresar manualmente",
|
||||
"foundSources": "Fuentes encontradas:",
|
||||
"reset": "Atrás",
|
||||
"submit": "Enviar lugar",
|
||||
"submitting": "Enviando...",
|
||||
"loginRequired": "Inicia sesión para agregar lugares.",
|
||||
"error": "Error al enviar. Inténtalo de nuevo.",
|
||||
"imageUrl": "URL de imagen (opcional)",
|
||||
"imageUrlPlaceholder": "https://example.com/imagen.jpg",
|
||||
"imagePreview": "Vista previa de imagen",
|
||||
"imageLoadError": "No se pudo cargar la imagen.",
|
||||
"imageRetry": "Reintentar",
|
||||
"geocoding": "Buscando coordenadas...",
|
||||
"coordinatesFound": "Coordenadas encontradas",
|
||||
"website": "Sitio web (opcional)",
|
||||
"websitePlaceholder": "https://example.com",
|
||||
"phone": "Teléfono (opcional)",
|
||||
"phonePlaceholder": "+34 91 123 4567"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar lugar",
|
||||
"subtitle": "Actualiza los detalles de este lugar",
|
||||
"save": "Guardar cambios",
|
||||
"saving": "Guardando...",
|
||||
"cancel": "Cancelar",
|
||||
"error": "Error al guardar. Inténtalo de nuevo.",
|
||||
"loadError": "No se pudo cargar el lugar.",
|
||||
"forbidden": "Solo puedes editar tus propios lugares."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Reseñas",
|
||||
"write": "Escribir reseña",
|
||||
"yourRating": "Tu valoración",
|
||||
"commentPlaceholder": "¿Qué te gustó? (opcional)",
|
||||
"submit": "Enviar",
|
||||
"submitting": "Enviando...",
|
||||
"loginRequired": "Inicia sesión para escribir una reseña.",
|
||||
"alreadyReviewed": "Ya has opinado sobre este lugar.",
|
||||
"deleteConfirm": "¿Eliminar reseña?",
|
||||
"delete": "Eliminar",
|
||||
"noReviews": "Aún no hay reseñas. ¡Sé el primero!",
|
||||
"error": "No se pudo guardar la reseña.",
|
||||
"count": "{count} reseñas",
|
||||
"countOne": "1 reseña"
|
||||
},
|
||||
"offline": {
|
||||
"title": "Sin conexión",
|
||||
"message": "Estás sin conexión. Puedes seguir usando CityCorners cuando tengas conexión a internet.",
|
||||
"retry": "Reintentar"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "CityCorners",
|
||||
"tagline": "Découvrez des villes du monde entier"
|
||||
},
|
||||
"nav": {
|
||||
"explore": "Explorer",
|
||||
"map": "Carte",
|
||||
"add": "Ajouter",
|
||||
"favorites": "Favoris",
|
||||
"settings": "Paramètres",
|
||||
"showNav": "Afficher la navigation",
|
||||
"hideNav": "Masquer la navigation",
|
||||
"cities": "Villes"
|
||||
},
|
||||
"cities": {
|
||||
"title": "Découvrir des villes",
|
||||
"subtitle": "Par la communauté, pour la communauté",
|
||||
"search": "Rechercher des villes...",
|
||||
"add": "Ajouter une ville",
|
||||
"empty": "Pas encore de villes. Soyez le premier !",
|
||||
"locationsCount": "{count} lieux",
|
||||
"noLocationsYet": "Pas encore de lieux",
|
||||
"contributors": "{count} contributeurs",
|
||||
"contributorsOne": "1 contributeur",
|
||||
"onMap": "{count} sur la carte",
|
||||
"recentlyAdded": "Ajoutés récemment",
|
||||
"stats": "Statistiques",
|
||||
"totalCities": "{count} villes",
|
||||
"totalLocations": "{count} lieux",
|
||||
"totalContributors": "{count} contributeurs",
|
||||
"topCategories": "Catégories principales"
|
||||
},
|
||||
"cityAdd": {
|
||||
"title": "Ajouter une ville",
|
||||
"subtitle": "Ajoutez une ville, un village ou une localité",
|
||||
"name": "Nom",
|
||||
"namePlaceholder": "ex. Paris",
|
||||
"country": "Pays",
|
||||
"countryPlaceholder": "ex. France",
|
||||
"state": "Région / Département (optionnel)",
|
||||
"statePlaceholder": "ex. Île-de-France",
|
||||
"description": "Description (optionnel)",
|
||||
"descriptionPlaceholder": "Qu'est-ce qui rend cet endroit spécial ?",
|
||||
"imageUrl": "URL de l'image (optionnel)",
|
||||
"imageUrlPlaceholder": "https://example.com/image.jpg",
|
||||
"submit": "Ajouter la ville",
|
||||
"submitting": "Création...",
|
||||
"loginRequired": "Connectez-vous pour ajouter des villes.",
|
||||
"error": "Échec de la création. Veuillez réessayer.",
|
||||
"geocoding": "Recherche des coordonnées...",
|
||||
"coordinatesFound": "Coordonnées trouvées",
|
||||
"slugExists": "Une ville avec ce nom existe déjà."
|
||||
},
|
||||
"home": {
|
||||
"title": "Découvrir des lieux",
|
||||
"subtitle": "Monuments, restaurants, musées et plus",
|
||||
"all": "Tous",
|
||||
"loading": "Chargement...",
|
||||
"noResults": "Aucun lieu trouvé.",
|
||||
"noResultsCategory": "Aucun {category} trouvé.",
|
||||
"addFirst": "Ajoutez le premier lieu",
|
||||
"loadMore": "Charger plus"
|
||||
},
|
||||
"categories": {
|
||||
"sight": "Monuments",
|
||||
"restaurant": "Restaurants",
|
||||
"shop": "Boutiques",
|
||||
"museum": "Musées",
|
||||
"cafe": "Cafés",
|
||||
"bar": "Bars",
|
||||
"park": "Parcs",
|
||||
"beach": "Plages",
|
||||
"hotel": "Hôtels",
|
||||
"event_venue": "Salles d'événements",
|
||||
"viewpoint": "Points de vue"
|
||||
},
|
||||
"category": {
|
||||
"sight": "Monument",
|
||||
"restaurant": "Restaurant",
|
||||
"shop": "Boutique",
|
||||
"museum": "Musée",
|
||||
"cafe": "Café",
|
||||
"bar": "Bar",
|
||||
"park": "Parc",
|
||||
"beach": "Plage",
|
||||
"hotel": "Hôtel",
|
||||
"event_venue": "Salle d'événements",
|
||||
"viewpoint": "Point de vue"
|
||||
},
|
||||
"detail": {
|
||||
"history": "Histoire",
|
||||
"openInMaps": "OSM",
|
||||
"showOnMap": "Sur la carte",
|
||||
"directions": "Itinéraire",
|
||||
"share": "Partager",
|
||||
"linkCopied": "Lien copié !",
|
||||
"showDetails": "Détails",
|
||||
"back": "Retour à la liste",
|
||||
"notFound": "Lieu introuvable.",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"deleteConfirm": "Voulez-vous vraiment supprimer ce lieu ? Cette action est irréversible.",
|
||||
"confirmDelete": "Supprimer définitivement",
|
||||
"deleting": "Suppression...",
|
||||
"cancel": "Annuler",
|
||||
"nearby": "À proximité",
|
||||
"website": "Site web",
|
||||
"phone": "Téléphone",
|
||||
"openingHours": "Horaires d'ouverture",
|
||||
"closed": "Fermé",
|
||||
"openNow": "Ouvert",
|
||||
"closedNow": "Fermé"
|
||||
},
|
||||
"days": {
|
||||
"mo": "Lundi",
|
||||
"tu": "Mardi",
|
||||
"we": "Mercredi",
|
||||
"th": "Jeudi",
|
||||
"fr": "Vendredi",
|
||||
"sa": "Samedi",
|
||||
"su": "Dimanche"
|
||||
},
|
||||
"gallery": {
|
||||
"addPhoto": "Ajouter une photo",
|
||||
"add": "Ajouter",
|
||||
"addError": "Impossible d'ajouter la photo."
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favoris",
|
||||
"subtitle": "Vos lieux enregistrés",
|
||||
"empty": "Pas encore de favoris. Appuyez sur le cœur pour sauvegarder un lieu.",
|
||||
"loginRequired": "Connectez-vous pour sauvegarder vos favoris.",
|
||||
"add": "Ajouter aux favoris",
|
||||
"remove": "Retirer des favoris",
|
||||
"tabFavorites": "Favoris",
|
||||
"tabCollections": "Collections"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"empty": "Pas encore de collections.",
|
||||
"create": "Créer une collection",
|
||||
"name": "Nom",
|
||||
"namePlaceholder": "ex. Mes restaurants préférés",
|
||||
"description": "Description (optionnel)",
|
||||
"descriptionPlaceholder": "De quoi parle cette collection ?",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"locations": "{count} lieux",
|
||||
"noLocations": "Aucun lieu dans cette collection.",
|
||||
"delete": "Supprimer la collection",
|
||||
"deleteConfirm": "Voulez-vous vraiment supprimer cette collection ?",
|
||||
"back": "Retour aux collections"
|
||||
},
|
||||
"map": {
|
||||
"title": "Carte",
|
||||
"subtitle": "Tous les lieux sur la carte",
|
||||
"locateMe": "Ma position",
|
||||
"yourLocation": "Vous êtes ici",
|
||||
"geolocationNotSupported": "La géolocalisation n'est pas disponible.",
|
||||
"geolocationError": "Impossible de déterminer la position.",
|
||||
"filterAll": "Tous"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher des lieux...",
|
||||
"noResults": "Aucun résultat",
|
||||
"searching": "Recherche..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"appearance": "Apparence",
|
||||
"mode": "Mode",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système",
|
||||
"colorScheme": "Thème de couleur",
|
||||
"account": "Compte",
|
||||
"email": "Email",
|
||||
"logout": "Déconnexion",
|
||||
"loginPrompt": "Connectez-vous pour sauvegarder vos favoris et utiliser toutes les fonctions.",
|
||||
"login": "Connexion",
|
||||
"register": "Inscription",
|
||||
"about": "À propos de CityCorners",
|
||||
"aboutText": "CityCorners est une plateforme ouverte de guides de villes du monde entier. Découvrez des lieux partagés par la communauté ou ajoutez votre propre ville."
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Connexion - CityCorners",
|
||||
"registerTitle": "Inscription - CityCorners"
|
||||
},
|
||||
"add": {
|
||||
"title": "Ajouter un lieu",
|
||||
"subtitle": "Partagez votre endroit préféré",
|
||||
"name": "Nom",
|
||||
"namePlaceholder": "ex. Café du Lac",
|
||||
"category": "Catégorie",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Qu'est-ce qui rend cet endroit spécial ?",
|
||||
"minChars": "Au moins 10 caractères",
|
||||
"address": "Adresse (optionnel)",
|
||||
"addressPlaceholder": "ex. 1 Rue Principale",
|
||||
"searchTitle": "Rechercher un lieu en ligne",
|
||||
"searchSubtitle": "Nous trouverons les informations et pré-remplirons le formulaire.",
|
||||
"searchPlaceholder": "ex. Café Zeitlos",
|
||||
"searchButton": "Rechercher",
|
||||
"skipSearch": "Passer et saisir manuellement",
|
||||
"foundSources": "Sources trouvées :",
|
||||
"reset": "Retour",
|
||||
"submit": "Soumettre le lieu",
|
||||
"submitting": "Envoi...",
|
||||
"loginRequired": "Connectez-vous pour ajouter des lieux.",
|
||||
"error": "Échec de l'envoi. Veuillez réessayer.",
|
||||
"imageUrl": "URL de l'image (optionnel)",
|
||||
"imageUrlPlaceholder": "https://example.com/image.jpg",
|
||||
"imagePreview": "Aperçu de l'image",
|
||||
"imageLoadError": "Impossible de charger l'image.",
|
||||
"imageRetry": "Réessayer",
|
||||
"geocoding": "Recherche des coordonnées...",
|
||||
"coordinatesFound": "Coordonnées trouvées",
|
||||
"website": "Site web (optionnel)",
|
||||
"websitePlaceholder": "https://example.com",
|
||||
"phone": "Téléphone (optionnel)",
|
||||
"phonePlaceholder": "+33 1 23 45 67 89"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le lieu",
|
||||
"subtitle": "Mettez à jour les détails de ce lieu",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"cancel": "Annuler",
|
||||
"error": "Échec de la sauvegarde. Veuillez réessayer.",
|
||||
"loadError": "Impossible de charger le lieu.",
|
||||
"forbidden": "Vous ne pouvez modifier que vos propres lieux."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Avis",
|
||||
"write": "Écrire un avis",
|
||||
"yourRating": "Votre note",
|
||||
"commentPlaceholder": "Qu'avez-vous aimé ? (optionnel)",
|
||||
"submit": "Envoyer",
|
||||
"submitting": "Envoi...",
|
||||
"loginRequired": "Connectez-vous pour écrire un avis.",
|
||||
"alreadyReviewed": "Vous avez déjà donné votre avis sur ce lieu.",
|
||||
"deleteConfirm": "Supprimer l'avis ?",
|
||||
"delete": "Supprimer",
|
||||
"noReviews": "Pas encore d'avis. Soyez le premier !",
|
||||
"error": "Impossible de sauvegarder l'avis.",
|
||||
"count": "{count} avis",
|
||||
"countOne": "1 avis"
|
||||
},
|
||||
"offline": {
|
||||
"title": "Pas de connexion",
|
||||
"message": "Vous êtes hors ligne. Vous pourrez utiliser CityCorners dès que vous aurez une connexion internet.",
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "CityCorners",
|
||||
"tagline": "Scopri città in tutto il mondo"
|
||||
},
|
||||
"nav": {
|
||||
"explore": "Esplora",
|
||||
"map": "Mappa",
|
||||
"add": "Aggiungi",
|
||||
"favorites": "Preferiti",
|
||||
"settings": "Impostazioni",
|
||||
"showNav": "Mostra navigazione",
|
||||
"hideNav": "Nascondi navigazione",
|
||||
"cities": "Città"
|
||||
},
|
||||
"cities": {
|
||||
"title": "Scopri città",
|
||||
"subtitle": "Dalla community, per la community",
|
||||
"search": "Cerca città...",
|
||||
"add": "Aggiungi città",
|
||||
"empty": "Ancora nessuna città. Sii il primo!",
|
||||
"locationsCount": "{count} luoghi",
|
||||
"noLocationsYet": "Ancora nessun luogo",
|
||||
"contributors": "{count} collaboratori",
|
||||
"contributorsOne": "1 collaboratore",
|
||||
"onMap": "{count} sulla mappa",
|
||||
"recentlyAdded": "Aggiunti di recente",
|
||||
"stats": "Statistiche",
|
||||
"totalCities": "{count} città",
|
||||
"totalLocations": "{count} luoghi",
|
||||
"totalContributors": "{count} collaboratori",
|
||||
"topCategories": "Categorie principali"
|
||||
},
|
||||
"cityAdd": {
|
||||
"title": "Aggiungi nuova città",
|
||||
"subtitle": "Aggiungi una città, un paese o una località",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "es. Roma",
|
||||
"country": "Paese",
|
||||
"countryPlaceholder": "es. Italia",
|
||||
"state": "Regione / Provincia (opzionale)",
|
||||
"statePlaceholder": "es. Lazio",
|
||||
"description": "Descrizione (opzionale)",
|
||||
"descriptionPlaceholder": "Cosa rende speciale questo posto?",
|
||||
"imageUrl": "URL immagine (opzionale)",
|
||||
"imageUrlPlaceholder": "https://example.com/immagine.jpg",
|
||||
"submit": "Aggiungi città",
|
||||
"submitting": "Creazione...",
|
||||
"loginRequired": "Accedi per aggiungere città.",
|
||||
"error": "Creazione fallita. Riprova.",
|
||||
"geocoding": "Ricerca coordinate...",
|
||||
"coordinatesFound": "Coordinate trovate",
|
||||
"slugExists": "Esiste già una città con questo nome."
|
||||
},
|
||||
"home": {
|
||||
"title": "Scopri luoghi",
|
||||
"subtitle": "Monumenti, ristoranti, musei e altro",
|
||||
"all": "Tutti",
|
||||
"loading": "Caricamento...",
|
||||
"noResults": "Nessun luogo trovato.",
|
||||
"noResultsCategory": "Nessun {category} trovato.",
|
||||
"addFirst": "Aggiungi il primo luogo",
|
||||
"loadMore": "Carica altri"
|
||||
},
|
||||
"categories": {
|
||||
"sight": "Monumenti",
|
||||
"restaurant": "Ristoranti",
|
||||
"shop": "Negozi",
|
||||
"museum": "Musei",
|
||||
"cafe": "Caffè",
|
||||
"bar": "Bar",
|
||||
"park": "Parchi",
|
||||
"beach": "Spiagge",
|
||||
"hotel": "Hotel",
|
||||
"event_venue": "Sale eventi",
|
||||
"viewpoint": "Punti panoramici"
|
||||
},
|
||||
"category": {
|
||||
"sight": "Monumento",
|
||||
"restaurant": "Ristorante",
|
||||
"shop": "Negozio",
|
||||
"museum": "Museo",
|
||||
"cafe": "Caffè",
|
||||
"bar": "Bar",
|
||||
"park": "Parco",
|
||||
"beach": "Spiaggia",
|
||||
"hotel": "Hotel",
|
||||
"event_venue": "Sala eventi",
|
||||
"viewpoint": "Punto panoramico"
|
||||
},
|
||||
"detail": {
|
||||
"history": "Storia",
|
||||
"openInMaps": "OSM",
|
||||
"showOnMap": "Sulla mappa",
|
||||
"directions": "Indicazioni",
|
||||
"share": "Condividi",
|
||||
"linkCopied": "Link copiato!",
|
||||
"showDetails": "Dettagli",
|
||||
"back": "Torna alla lista",
|
||||
"notFound": "Luogo non trovato.",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"deleteConfirm": "Vuoi davvero eliminare questo luogo? Non è possibile annullare.",
|
||||
"confirmDelete": "Elimina definitivamente",
|
||||
"deleting": "Eliminazione...",
|
||||
"cancel": "Annulla",
|
||||
"nearby": "Nelle vicinanze",
|
||||
"website": "Sito web",
|
||||
"phone": "Telefono",
|
||||
"openingHours": "Orari di apertura",
|
||||
"closed": "Chiuso",
|
||||
"openNow": "Aperto ora",
|
||||
"closedNow": "Chiuso"
|
||||
},
|
||||
"days": {
|
||||
"mo": "Lunedì",
|
||||
"tu": "Martedì",
|
||||
"we": "Mercoledì",
|
||||
"th": "Giovedì",
|
||||
"fr": "Venerdì",
|
||||
"sa": "Sabato",
|
||||
"su": "Domenica"
|
||||
},
|
||||
"gallery": {
|
||||
"addPhoto": "Aggiungi foto",
|
||||
"add": "Aggiungi",
|
||||
"addError": "Impossibile aggiungere la foto."
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Preferiti",
|
||||
"subtitle": "I tuoi luoghi salvati",
|
||||
"empty": "Ancora nessun preferito. Tocca il cuore su un luogo per salvarlo.",
|
||||
"loginRequired": "Accedi per salvare i preferiti.",
|
||||
"add": "Aggiungi ai preferiti",
|
||||
"remove": "Rimuovi dai preferiti",
|
||||
"tabFavorites": "Preferiti",
|
||||
"tabCollections": "Collezioni"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collezioni",
|
||||
"empty": "Ancora nessuna collezione.",
|
||||
"create": "Crea collezione",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "es. I miei ristoranti preferiti",
|
||||
"description": "Descrizione (opzionale)",
|
||||
"descriptionPlaceholder": "Di cosa tratta questa collezione?",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"locations": "{count} luoghi",
|
||||
"noLocations": "Nessun luogo in questa collezione.",
|
||||
"delete": "Elimina collezione",
|
||||
"deleteConfirm": "Vuoi davvero eliminare questa collezione?",
|
||||
"back": "Torna alle collezioni"
|
||||
},
|
||||
"map": {
|
||||
"title": "Mappa",
|
||||
"subtitle": "Tutti i luoghi sulla mappa",
|
||||
"locateMe": "La mia posizione",
|
||||
"yourLocation": "Sei qui",
|
||||
"geolocationNotSupported": "La geolocalizzazione non è disponibile.",
|
||||
"geolocationError": "Impossibile determinare la posizione.",
|
||||
"filterAll": "Tutti"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Cerca luoghi...",
|
||||
"noResults": "Nessun risultato",
|
||||
"searching": "Ricerca..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"appearance": "Aspetto",
|
||||
"mode": "Modalità",
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"system": "Sistema",
|
||||
"colorScheme": "Schema colori",
|
||||
"account": "Account",
|
||||
"email": "Email",
|
||||
"logout": "Esci",
|
||||
"loginPrompt": "Accedi per salvare i preferiti e usare tutte le funzioni.",
|
||||
"login": "Accedi",
|
||||
"register": "Registrati",
|
||||
"about": "Informazioni su CityCorners",
|
||||
"aboutText": "CityCorners è una piattaforma aperta per guide di città in tutto il mondo. Scopri luoghi condivisi dalla community o aggiungi la tua città."
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Login - CityCorners",
|
||||
"registerTitle": "Registrazione - CityCorners"
|
||||
},
|
||||
"add": {
|
||||
"title": "Aggiungi un luogo",
|
||||
"subtitle": "Condividi il tuo posto preferito",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "es. Caffè del Lago",
|
||||
"category": "Categoria",
|
||||
"description": "Descrizione",
|
||||
"descriptionPlaceholder": "Cosa rende speciale questo posto?",
|
||||
"minChars": "Almeno 10 caratteri",
|
||||
"address": "Indirizzo (opzionale)",
|
||||
"addressPlaceholder": "es. Via Roma 1",
|
||||
"searchTitle": "Cerca un luogo online",
|
||||
"searchSubtitle": "Troveremo le informazioni e compileremo il modulo automaticamente.",
|
||||
"searchPlaceholder": "es. Caffè Zeitlos",
|
||||
"searchButton": "Cerca",
|
||||
"skipSearch": "Salta e inserisci manualmente",
|
||||
"foundSources": "Fonti trovate:",
|
||||
"reset": "Indietro",
|
||||
"submit": "Invia luogo",
|
||||
"submitting": "Invio...",
|
||||
"loginRequired": "Accedi per aggiungere luoghi.",
|
||||
"error": "Invio fallito. Riprova.",
|
||||
"imageUrl": "URL immagine (opzionale)",
|
||||
"imageUrlPlaceholder": "https://example.com/immagine.jpg",
|
||||
"imagePreview": "Anteprima immagine",
|
||||
"imageLoadError": "Impossibile caricare l'immagine.",
|
||||
"imageRetry": "Riprova",
|
||||
"geocoding": "Ricerca coordinate...",
|
||||
"coordinatesFound": "Coordinate trovate",
|
||||
"website": "Sito web (opzionale)",
|
||||
"websitePlaceholder": "https://example.com",
|
||||
"phone": "Telefono (opzionale)",
|
||||
"phonePlaceholder": "+39 06 1234567"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifica luogo",
|
||||
"subtitle": "Aggiorna i dettagli di questo luogo",
|
||||
"save": "Salva modifiche",
|
||||
"saving": "Salvataggio...",
|
||||
"cancel": "Annulla",
|
||||
"error": "Salvataggio fallito. Riprova.",
|
||||
"loadError": "Impossibile caricare il luogo.",
|
||||
"forbidden": "Puoi modificare solo i tuoi luoghi."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Recensioni",
|
||||
"write": "Scrivi una recensione",
|
||||
"yourRating": "La tua valutazione",
|
||||
"commentPlaceholder": "Cosa ti è piaciuto? (opzionale)",
|
||||
"submit": "Invia",
|
||||
"submitting": "Invio...",
|
||||
"loginRequired": "Accedi per scrivere una recensione.",
|
||||
"alreadyReviewed": "Hai già recensito questo luogo.",
|
||||
"deleteConfirm": "Eliminare la recensione?",
|
||||
"delete": "Elimina",
|
||||
"noReviews": "Ancora nessuna recensione. Sii il primo!",
|
||||
"error": "Impossibile salvare la recensione.",
|
||||
"count": "{count} recensioni",
|
||||
"countOne": "1 recensione"
|
||||
},
|
||||
"offline": {
|
||||
"title": "Nessuna connessione",
|
||||
"message": "Sei offline. Potrai usare CityCorners quando avrai una connessione internet.",
|
||||
"retry": "Riprova"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Food",
|
||||
"loading": "Laden...",
|
||||
"tagline": "Ernährung verstehen"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"meals": "Mahlzeiten",
|
||||
"goals": "Ziele",
|
||||
"favorites": "Favoriten",
|
||||
"stats": "Statistiken",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"meal": {
|
||||
"add": "Mahlzeit hinzufügen",
|
||||
"edit": "Mahlzeit bearbeiten",
|
||||
"delete": "Mahlzeit löschen",
|
||||
"photo": "Foto aufnehmen",
|
||||
"text": "Beschreiben",
|
||||
"analyzing": "Analysiere...",
|
||||
"noMeals": "Noch keine Mahlzeiten",
|
||||
"breakfast": "Frühstück",
|
||||
"lunch": "Mittagessen",
|
||||
"dinner": "Abendessen",
|
||||
"snack": "Snack"
|
||||
},
|
||||
"nutrition": {
|
||||
"calories": "Kalorien",
|
||||
"protein": "Protein",
|
||||
"carbs": "Kohlenhydrate",
|
||||
"fat": "Fett",
|
||||
"fiber": "Ballaststoffe",
|
||||
"sugar": "Zucker",
|
||||
"kcal": "kcal",
|
||||
"grams": "g"
|
||||
},
|
||||
"goals": {
|
||||
"daily": "Tagesziele",
|
||||
"setGoals": "Ziele setzen",
|
||||
"calories": "Kalorien-Ziel",
|
||||
"protein": "Protein-Ziel",
|
||||
"carbs": "Kohlenhydrate-Ziel",
|
||||
"fat": "Fett-Ziel",
|
||||
"progress": "Fortschritt"
|
||||
},
|
||||
"stats": {
|
||||
"today": "Heute",
|
||||
"week": "Diese Woche",
|
||||
"remaining": "Verbleibend",
|
||||
"consumed": "Verzehrt",
|
||||
"average": "Durchschnitt"
|
||||
},
|
||||
"favorites": {
|
||||
"add": "Zu Favoriten",
|
||||
"remove": "Aus Favoriten entfernen",
|
||||
"noFavorites": "Keine Favoriten",
|
||||
"useAgain": "Erneut verwenden"
|
||||
},
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
"close": "Schließen",
|
||||
"search": "Suchen",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolgreich",
|
||||
"loading": "Laden..."
|
||||
},
|
||||
"errors": {
|
||||
"loadMeals": "Mahlzeiten konnten nicht geladen werden",
|
||||
"analyzeFailed": "Analyse fehlgeschlagen",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"loadGoals": "Ziele konnten nicht geladen werden"
|
||||
},
|
||||
"success": {
|
||||
"mealAdded": "Mahlzeit hinzugefügt",
|
||||
"mealDeleted": "Mahlzeit gelöscht",
|
||||
"goalsSaved": "Ziele gespeichert",
|
||||
"favoriteAdded": "Zu Favoriten hinzugefügt"
|
||||
},
|
||||
"home": {
|
||||
"page_title_html": "Food - Mana",
|
||||
"heading_today": "Heute",
|
||||
"action_history": "Verlauf",
|
||||
"action_meal": "Mahlzeit",
|
||||
"section_today_meals": "Heutige Mahlzeiten",
|
||||
"entries_count": "{n} Einträge",
|
||||
"empty_no_meals": "Noch keine Mahlzeiten",
|
||||
"empty_hint": "Trage deine erste Mahlzeit ein.",
|
||||
"action_add_meal": "Mahlzeit hinzufügen",
|
||||
"macro_protein": "{n}g Protein",
|
||||
"macro_carbs": "{n}g Carbs",
|
||||
"macro_fat": "{n}g Fett",
|
||||
"link_goals": "Ziele"
|
||||
},
|
||||
"detail": {
|
||||
"page_title_html": "{description} - Food - Mana",
|
||||
"untitled_fallback": "Mahlzeit",
|
||||
"back": "Zurück",
|
||||
"not_found": "Mahlzeit nicht gefunden.",
|
||||
"lightbox_open_aria": "Bild vergrößern",
|
||||
"lightbox_close_aria": "Bild schließen",
|
||||
"section_foods": "Erkannte Bestandteile",
|
||||
"section_nutrients": "Nährwerte",
|
||||
"label_meal_type": "Mahlzeittyp",
|
||||
"label_description": "Beschreibung",
|
||||
"label_calories_kcal": "Kalorien (kcal)",
|
||||
"label_protein_g": "Protein (g)",
|
||||
"label_carbs_g": "Kohlenhydrate (g)",
|
||||
"label_fat_g": "Fett (g)",
|
||||
"label_fiber_g": "Ballaststoffe (g)",
|
||||
"label_sugar_g": "Zucker (g)",
|
||||
"fiber_with_value": "Ballaststoffe: {n}g",
|
||||
"sugar_with_value": "Zucker: {n}g",
|
||||
"action_reanalyze": "🔄 Erneut analysieren",
|
||||
"action_reanalyzing": "Analysiere…",
|
||||
"action_saving": "Speichere…",
|
||||
"confirm_sure": "Sicher?",
|
||||
"error_description_required": "Beschreibung darf nicht leer sein",
|
||||
"error_save_failed": "Speichern fehlgeschlagen",
|
||||
"error_analyze_failed": "KI-Analyse fehlgeschlagen",
|
||||
"error_delete_failed": "Löschen fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Food",
|
||||
"loading": "Loading...",
|
||||
"tagline": "Understand nutrition"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"meals": "Meals",
|
||||
"goals": "Goals",
|
||||
"favorites": "Favorites",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"meal": {
|
||||
"add": "Add meal",
|
||||
"edit": "Edit meal",
|
||||
"delete": "Delete meal",
|
||||
"photo": "Take photo",
|
||||
"text": "Describe",
|
||||
"analyzing": "Analyzing...",
|
||||
"noMeals": "No meals yet",
|
||||
"breakfast": "Breakfast",
|
||||
"lunch": "Lunch",
|
||||
"dinner": "Dinner",
|
||||
"snack": "Snack"
|
||||
},
|
||||
"nutrition": {
|
||||
"calories": "Calories",
|
||||
"protein": "Protein",
|
||||
"carbs": "Carbohydrates",
|
||||
"fat": "Fat",
|
||||
"fiber": "Fiber",
|
||||
"sugar": "Sugar",
|
||||
"kcal": "kcal",
|
||||
"grams": "g"
|
||||
},
|
||||
"goals": {
|
||||
"daily": "Daily goals",
|
||||
"setGoals": "Set goals",
|
||||
"calories": "Calorie goal",
|
||||
"protein": "Protein goal",
|
||||
"carbs": "Carbohydrate goal",
|
||||
"fat": "Fat goal",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"stats": {
|
||||
"today": "Today",
|
||||
"week": "This week",
|
||||
"remaining": "Remaining",
|
||||
"consumed": "Consumed",
|
||||
"average": "Average"
|
||||
},
|
||||
"favorites": {
|
||||
"add": "Add to favorites",
|
||||
"remove": "Remove from favorites",
|
||||
"noFavorites": "No favorites",
|
||||
"useAgain": "Use again"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"close": "Close",
|
||||
"search": "Search",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"errors": {
|
||||
"loadMeals": "Failed to load meals",
|
||||
"analyzeFailed": "Analysis failed",
|
||||
"saveFailed": "Failed to save",
|
||||
"loadGoals": "Failed to load goals"
|
||||
},
|
||||
"success": {
|
||||
"mealAdded": "Meal added",
|
||||
"mealDeleted": "Meal deleted",
|
||||
"goalsSaved": "Goals saved",
|
||||
"favoriteAdded": "Added to favorites"
|
||||
},
|
||||
"home": {
|
||||
"page_title_html": "Food - Mana",
|
||||
"heading_today": "Today",
|
||||
"action_history": "History",
|
||||
"action_meal": "Meal",
|
||||
"section_today_meals": "Today's meals",
|
||||
"entries_count": "{n} entries",
|
||||
"empty_no_meals": "No meals yet",
|
||||
"empty_hint": "Log your first meal.",
|
||||
"action_add_meal": "Add meal",
|
||||
"macro_protein": "{n}g protein",
|
||||
"macro_carbs": "{n}g carbs",
|
||||
"macro_fat": "{n}g fat",
|
||||
"link_goals": "Goals"
|
||||
},
|
||||
"detail": {
|
||||
"page_title_html": "{description} - Food - Mana",
|
||||
"untitled_fallback": "Meal",
|
||||
"back": "Back",
|
||||
"not_found": "Meal not found.",
|
||||
"lightbox_open_aria": "Enlarge image",
|
||||
"lightbox_close_aria": "Close image",
|
||||
"section_foods": "Detected items",
|
||||
"section_nutrients": "Nutrients",
|
||||
"label_meal_type": "Meal type",
|
||||
"label_description": "Description",
|
||||
"label_calories_kcal": "Calories (kcal)",
|
||||
"label_protein_g": "Protein (g)",
|
||||
"label_carbs_g": "Carbohydrates (g)",
|
||||
"label_fat_g": "Fat (g)",
|
||||
"label_fiber_g": "Fiber (g)",
|
||||
"label_sugar_g": "Sugar (g)",
|
||||
"fiber_with_value": "Fiber: {n}g",
|
||||
"sugar_with_value": "Sugar: {n}g",
|
||||
"action_reanalyze": "🔄 Re-analyze",
|
||||
"action_reanalyzing": "Analyzing…",
|
||||
"action_saving": "Saving…",
|
||||
"confirm_sure": "Sure?",
|
||||
"error_description_required": "Description cannot be empty",
|
||||
"error_save_failed": "Save failed",
|
||||
"error_analyze_failed": "AI analysis failed",
|
||||
"error_delete_failed": "Delete failed"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Food",
|
||||
"loading": "Cargando...",
|
||||
"tagline": "Entiende la nutrición"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel",
|
||||
"meals": "Comidas",
|
||||
"goals": "Objetivos",
|
||||
"favorites": "Favoritos",
|
||||
"stats": "Estadísticas",
|
||||
"settings": "Ajustes"
|
||||
},
|
||||
"meal": {
|
||||
"add": "Añadir comida",
|
||||
"edit": "Editar comida",
|
||||
"delete": "Eliminar comida",
|
||||
"photo": "Tomar foto",
|
||||
"text": "Describir",
|
||||
"analyzing": "Analizando...",
|
||||
"noMeals": "Aún no hay comidas",
|
||||
"breakfast": "Desayuno",
|
||||
"lunch": "Almuerzo",
|
||||
"dinner": "Cena",
|
||||
"snack": "Snack"
|
||||
},
|
||||
"nutrition": {
|
||||
"calories": "Calorías",
|
||||
"protein": "Proteínas",
|
||||
"carbs": "Carbohidratos",
|
||||
"fat": "Grasas",
|
||||
"fiber": "Fibra",
|
||||
"sugar": "Azúcar",
|
||||
"kcal": "kcal",
|
||||
"grams": "g"
|
||||
},
|
||||
"goals": {
|
||||
"daily": "Objetivos diarios",
|
||||
"setGoals": "Establecer objetivos",
|
||||
"calories": "Objetivo de calorías",
|
||||
"protein": "Objetivo de proteínas",
|
||||
"carbs": "Objetivo de carbohidratos",
|
||||
"fat": "Objetivo de grasas",
|
||||
"progress": "Progreso"
|
||||
},
|
||||
"stats": {
|
||||
"today": "Hoy",
|
||||
"week": "Esta semana",
|
||||
"remaining": "Restante",
|
||||
"consumed": "Consumido",
|
||||
"average": "Promedio"
|
||||
},
|
||||
"favorites": {
|
||||
"add": "Añadir a favoritos",
|
||||
"remove": "Quitar de favoritos",
|
||||
"noFavorites": "Sin favoritos",
|
||||
"useAgain": "Usar de nuevo"
|
||||
},
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"add": "Añadir",
|
||||
"close": "Cerrar",
|
||||
"search": "Buscar",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
"loading": "Cargando..."
|
||||
},
|
||||
"errors": {
|
||||
"loadMeals": "No se pudieron cargar las comidas",
|
||||
"analyzeFailed": "El análisis falló",
|
||||
"saveFailed": "No se pudo guardar",
|
||||
"loadGoals": "No se pudieron cargar los objetivos"
|
||||
},
|
||||
"success": {
|
||||
"mealAdded": "Comida añadida",
|
||||
"mealDeleted": "Comida eliminada",
|
||||
"goalsSaved": "Objetivos guardados",
|
||||
"favoriteAdded": "Añadido a favoritos"
|
||||
},
|
||||
"home": {
|
||||
"page_title_html": "Food - Mana",
|
||||
"heading_today": "Hoy",
|
||||
"action_history": "Historial",
|
||||
"action_meal": "Comida",
|
||||
"section_today_meals": "Comidas de hoy",
|
||||
"entries_count": "{n} entradas",
|
||||
"empty_no_meals": "Sin comidas",
|
||||
"empty_hint": "Registra tu primera comida.",
|
||||
"action_add_meal": "Añadir comida",
|
||||
"macro_protein": "{n}g proteína",
|
||||
"macro_carbs": "{n}g carbs",
|
||||
"macro_fat": "{n}g grasa",
|
||||
"link_goals": "Objetivos"
|
||||
},
|
||||
"detail": {
|
||||
"page_title_html": "{description} - Food - Mana",
|
||||
"untitled_fallback": "Comida",
|
||||
"back": "Atrás",
|
||||
"not_found": "Comida no encontrada.",
|
||||
"lightbox_open_aria": "Ampliar imagen",
|
||||
"lightbox_close_aria": "Cerrar imagen",
|
||||
"section_foods": "Componentes detectados",
|
||||
"section_nutrients": "Nutrientes",
|
||||
"label_meal_type": "Tipo de comida",
|
||||
"label_description": "Descripción",
|
||||
"label_calories_kcal": "Calorías (kcal)",
|
||||
"label_protein_g": "Proteína (g)",
|
||||
"label_carbs_g": "Carbohidratos (g)",
|
||||
"label_fat_g": "Grasa (g)",
|
||||
"label_fiber_g": "Fibra (g)",
|
||||
"label_sugar_g": "Azúcar (g)",
|
||||
"fiber_with_value": "Fibra: {n}g",
|
||||
"sugar_with_value": "Azúcar: {n}g",
|
||||
"action_reanalyze": "🔄 Reanalizar",
|
||||
"action_reanalyzing": "Analizando…",
|
||||
"action_saving": "Guardando…",
|
||||
"confirm_sure": "¿Seguro?",
|
||||
"error_description_required": "La descripción no puede estar vacía",
|
||||
"error_save_failed": "Error al guardar",
|
||||
"error_analyze_failed": "Error en el análisis IA",
|
||||
"error_delete_failed": "Error al eliminar"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Food",
|
||||
"loading": "Chargement...",
|
||||
"tagline": "Comprendre la nutrition"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"meals": "Repas",
|
||||
"goals": "Objectifs",
|
||||
"favorites": "Favoris",
|
||||
"stats": "Statistiques",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"meal": {
|
||||
"add": "Ajouter un repas",
|
||||
"edit": "Modifier le repas",
|
||||
"delete": "Supprimer le repas",
|
||||
"photo": "Prendre une photo",
|
||||
"text": "Décrire",
|
||||
"analyzing": "Analyse en cours...",
|
||||
"noMeals": "Pas encore de repas",
|
||||
"breakfast": "Petit-déjeuner",
|
||||
"lunch": "Déjeuner",
|
||||
"dinner": "Dîner",
|
||||
"snack": "En-cas"
|
||||
},
|
||||
"nutrition": {
|
||||
"calories": "Calories",
|
||||
"protein": "Protéines",
|
||||
"carbs": "Glucides",
|
||||
"fat": "Lipides",
|
||||
"fiber": "Fibres",
|
||||
"sugar": "Sucre",
|
||||
"kcal": "kcal",
|
||||
"grams": "g"
|
||||
},
|
||||
"goals": {
|
||||
"daily": "Objectifs quotidiens",
|
||||
"setGoals": "Définir les objectifs",
|
||||
"calories": "Objectif calorique",
|
||||
"protein": "Objectif protéines",
|
||||
"carbs": "Objectif glucides",
|
||||
"fat": "Objectif lipides",
|
||||
"progress": "Progression"
|
||||
},
|
||||
"stats": {
|
||||
"today": "Aujourd'hui",
|
||||
"week": "Cette semaine",
|
||||
"remaining": "Restant",
|
||||
"consumed": "Consommé",
|
||||
"average": "Moyenne"
|
||||
},
|
||||
"favorites": {
|
||||
"add": "Ajouter aux favoris",
|
||||
"remove": "Retirer des favoris",
|
||||
"noFavorites": "Pas de favoris",
|
||||
"useAgain": "Réutiliser"
|
||||
},
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"add": "Ajouter",
|
||||
"close": "Fermer",
|
||||
"search": "Rechercher",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"loading": "Chargement..."
|
||||
},
|
||||
"errors": {
|
||||
"loadMeals": "Impossible de charger les repas",
|
||||
"analyzeFailed": "L'analyse a échoué",
|
||||
"saveFailed": "Impossible d'enregistrer",
|
||||
"loadGoals": "Impossible de charger les objectifs"
|
||||
},
|
||||
"success": {
|
||||
"mealAdded": "Repas ajouté",
|
||||
"mealDeleted": "Repas supprimé",
|
||||
"goalsSaved": "Objectifs enregistrés",
|
||||
"favoriteAdded": "Ajouté aux favoris"
|
||||
},
|
||||
"home": {
|
||||
"page_title_html": "Food - Mana",
|
||||
"heading_today": "Aujourd'hui",
|
||||
"action_history": "Historique",
|
||||
"action_meal": "Repas",
|
||||
"section_today_meals": "Repas du jour",
|
||||
"entries_count": "{n} entrées",
|
||||
"empty_no_meals": "Pas encore de repas",
|
||||
"empty_hint": "Enregistre ton premier repas.",
|
||||
"action_add_meal": "Ajouter un repas",
|
||||
"macro_protein": "{n}g protéines",
|
||||
"macro_carbs": "{n}g glucides",
|
||||
"macro_fat": "{n}g lipides",
|
||||
"link_goals": "Objectifs"
|
||||
},
|
||||
"detail": {
|
||||
"page_title_html": "{description} - Food - Mana",
|
||||
"untitled_fallback": "Repas",
|
||||
"back": "Retour",
|
||||
"not_found": "Repas introuvable.",
|
||||
"lightbox_open_aria": "Agrandir l'image",
|
||||
"lightbox_close_aria": "Fermer l'image",
|
||||
"section_foods": "Ingrédients détectés",
|
||||
"section_nutrients": "Nutriments",
|
||||
"label_meal_type": "Type de repas",
|
||||
"label_description": "Description",
|
||||
"label_calories_kcal": "Calories (kcal)",
|
||||
"label_protein_g": "Protéines (g)",
|
||||
"label_carbs_g": "Glucides (g)",
|
||||
"label_fat_g": "Lipides (g)",
|
||||
"label_fiber_g": "Fibres (g)",
|
||||
"label_sugar_g": "Sucres (g)",
|
||||
"fiber_with_value": "Fibres : {n}g",
|
||||
"sugar_with_value": "Sucres : {n}g",
|
||||
"action_reanalyze": "🔄 Ré-analyser",
|
||||
"action_reanalyzing": "Analyse…",
|
||||
"action_saving": "Enregistrement…",
|
||||
"confirm_sure": "Sûr ?",
|
||||
"error_description_required": "La description ne peut pas être vide",
|
||||
"error_save_failed": "Échec de l'enregistrement",
|
||||
"error_analyze_failed": "Échec de l'analyse IA",
|
||||
"error_delete_failed": "Échec de la suppression"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Food",
|
||||
"loading": "Caricamento...",
|
||||
"tagline": "Comprendi la nutrizione"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"meals": "Pasti",
|
||||
"goals": "Obiettivi",
|
||||
"favorites": "Preferiti",
|
||||
"stats": "Statistiche",
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"meal": {
|
||||
"add": "Aggiungi pasto",
|
||||
"edit": "Modifica pasto",
|
||||
"delete": "Elimina pasto",
|
||||
"photo": "Scatta foto",
|
||||
"text": "Descrivi",
|
||||
"analyzing": "Analisi in corso...",
|
||||
"noMeals": "Nessun pasto ancora",
|
||||
"breakfast": "Colazione",
|
||||
"lunch": "Pranzo",
|
||||
"dinner": "Cena",
|
||||
"snack": "Spuntino"
|
||||
},
|
||||
"nutrition": {
|
||||
"calories": "Calorie",
|
||||
"protein": "Proteine",
|
||||
"carbs": "Carboidrati",
|
||||
"fat": "Grassi",
|
||||
"fiber": "Fibre",
|
||||
"sugar": "Zucchero",
|
||||
"kcal": "kcal",
|
||||
"grams": "g"
|
||||
},
|
||||
"goals": {
|
||||
"daily": "Obiettivi giornalieri",
|
||||
"setGoals": "Imposta obiettivi",
|
||||
"calories": "Obiettivo calorie",
|
||||
"protein": "Obiettivo proteine",
|
||||
"carbs": "Obiettivo carboidrati",
|
||||
"fat": "Obiettivo grassi",
|
||||
"progress": "Progresso"
|
||||
},
|
||||
"stats": {
|
||||
"today": "Oggi",
|
||||
"week": "Questa settimana",
|
||||
"remaining": "Rimanente",
|
||||
"consumed": "Consumato",
|
||||
"average": "Media"
|
||||
},
|
||||
"favorites": {
|
||||
"add": "Aggiungi ai preferiti",
|
||||
"remove": "Rimuovi dai preferiti",
|
||||
"noFavorites": "Nessun preferito",
|
||||
"useAgain": "Usa di nuovo"
|
||||
},
|
||||
"common": {
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"edit": "Modifica",
|
||||
"add": "Aggiungi",
|
||||
"close": "Chiudi",
|
||||
"search": "Cerca",
|
||||
"error": "Errore",
|
||||
"success": "Successo",
|
||||
"loading": "Caricamento..."
|
||||
},
|
||||
"errors": {
|
||||
"loadMeals": "Impossibile caricare i pasti",
|
||||
"analyzeFailed": "Analisi fallita",
|
||||
"saveFailed": "Salvataggio fallito",
|
||||
"loadGoals": "Impossibile caricare gli obiettivi"
|
||||
},
|
||||
"success": {
|
||||
"mealAdded": "Pasto aggiunto",
|
||||
"mealDeleted": "Pasto eliminato",
|
||||
"goalsSaved": "Obiettivi salvati",
|
||||
"favoriteAdded": "Aggiunto ai preferiti"
|
||||
},
|
||||
"home": {
|
||||
"page_title_html": "Food - Mana",
|
||||
"heading_today": "Oggi",
|
||||
"action_history": "Storico",
|
||||
"action_meal": "Pasto",
|
||||
"section_today_meals": "Pasti di oggi",
|
||||
"entries_count": "{n} voci",
|
||||
"empty_no_meals": "Nessun pasto",
|
||||
"empty_hint": "Registra il tuo primo pasto.",
|
||||
"action_add_meal": "Aggiungi pasto",
|
||||
"macro_protein": "{n}g proteine",
|
||||
"macro_carbs": "{n}g carboidrati",
|
||||
"macro_fat": "{n}g grassi",
|
||||
"link_goals": "Obiettivi"
|
||||
},
|
||||
"detail": {
|
||||
"page_title_html": "{description} - Food - Mana",
|
||||
"untitled_fallback": "Pasto",
|
||||
"back": "Indietro",
|
||||
"not_found": "Pasto non trovato.",
|
||||
"lightbox_open_aria": "Ingrandisci immagine",
|
||||
"lightbox_close_aria": "Chiudi immagine",
|
||||
"section_foods": "Componenti rilevati",
|
||||
"section_nutrients": "Nutrienti",
|
||||
"label_meal_type": "Tipo di pasto",
|
||||
"label_description": "Descrizione",
|
||||
"label_calories_kcal": "Calorie (kcal)",
|
||||
"label_protein_g": "Proteine (g)",
|
||||
"label_carbs_g": "Carboidrati (g)",
|
||||
"label_fat_g": "Grassi (g)",
|
||||
"label_fiber_g": "Fibre (g)",
|
||||
"label_sugar_g": "Zuccheri (g)",
|
||||
"fiber_with_value": "Fibre: {n}g",
|
||||
"sugar_with_value": "Zuccheri: {n}g",
|
||||
"action_reanalyze": "🔄 Rianalizza",
|
||||
"action_reanalyzing": "Analisi…",
|
||||
"action_saving": "Salvataggio…",
|
||||
"confirm_sure": "Sicuro?",
|
||||
"error_description_required": "La descrizione non può essere vuota",
|
||||
"error_save_failed": "Salvataggio non riuscito",
|
||||
"error_analyze_failed": "Analisi AI non riuscita",
|
||||
"error_delete_failed": "Eliminazione non riuscita"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Moodlit",
|
||||
"tagline": "Ambient Lighting & Moods"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Startseite",
|
||||
"moods": "Moods",
|
||||
"sequences": "Sequenzen",
|
||||
"settings": "Einstellungen",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"home": {
|
||||
"title": "Deine Moods",
|
||||
"subtitle": "Wähle eine Lichtstimmung",
|
||||
"sequences": "Sequenzen",
|
||||
"sequencesDescription": "Verkette mehrere Moods zu einer Sequenz",
|
||||
"favorites": "Favoriten",
|
||||
"all": "Alle Moods",
|
||||
"custom": "Eigene Moods"
|
||||
},
|
||||
"sequences": {
|
||||
"title": "Sequenzen",
|
||||
"subtitle": "Spiele mehrere Moods nacheinander ab",
|
||||
"moods": "Moods",
|
||||
"empty": "Noch keine Sequenzen",
|
||||
"emptyDescription": "Erstelle eine Sequenz, indem du mehrere Moods verkettest."
|
||||
},
|
||||
"mood": {
|
||||
"play": "Abspielen",
|
||||
"pause": "Pause",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"addToFavorites": "Zu Favoriten",
|
||||
"removeFromFavorites": "Aus Favoriten",
|
||||
"animation": "Animation",
|
||||
"colors": "Farben",
|
||||
"startTimer": "Start",
|
||||
"stopTimer": "Timer stoppen",
|
||||
"timerRunning": "Timer läuft",
|
||||
"stop": "Stopp"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"animationSpeed": "Animationsgeschwindigkeit",
|
||||
"slow": "Langsam",
|
||||
"normal": "Normal",
|
||||
"fast": "Schnell",
|
||||
"brightness": "Helligkeit",
|
||||
"autoTimer": "Auto-Timer",
|
||||
"autoTimerOff": "Aus",
|
||||
"autoTimerMinutes": "{minutes} Minuten",
|
||||
"autoMoodSwitch": "Auto-Mood-Wechsel",
|
||||
"autoMoodSwitchInterval": "Wechsel-Intervall",
|
||||
"reset": "Zurücksetzen",
|
||||
"resetConfirm": "Alle Einstellungen zurücksetzen?"
|
||||
},
|
||||
"createMood": {
|
||||
"title": "Mood erstellen",
|
||||
"editTitle": "Mood bearbeiten",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Mood-Name eingeben...",
|
||||
"colors": "Farben",
|
||||
"addColor": "Farbe hinzufügen",
|
||||
"animation": "Animationstyp",
|
||||
"preview": "Vorschau"
|
||||
},
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"confirm": "Bestätigen",
|
||||
"loading": "Lädt...",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolgreich",
|
||||
"create": "Erstellen"
|
||||
},
|
||||
"moodsPage": {
|
||||
"page_title_html": "Moods - Moodlit - Mana",
|
||||
"title": "Moods",
|
||||
"action_close": "Schliessen",
|
||||
"action_new_mood": "+ Neues Mood",
|
||||
"label_name": "Name",
|
||||
"placeholder_name": "Mein Mood",
|
||||
"label_animation": "Animation",
|
||||
"label_colors": "Farben",
|
||||
"toast_created": "\"{name}\" erstellt",
|
||||
"toast_default_protected": "Standard-Moods können nicht gelöscht werden",
|
||||
"toast_deleted": "Gelöscht"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Moodlit",
|
||||
"tagline": "Ambient Lighting & Moods"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"moods": "Moods",
|
||||
"sequences": "Sequences",
|
||||
"settings": "Settings",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"home": {
|
||||
"title": "Your Moods",
|
||||
"subtitle": "Choose a lighting mood",
|
||||
"sequences": "Sequences",
|
||||
"sequencesDescription": "Chain multiple moods into a sequence",
|
||||
"favorites": "Favorites",
|
||||
"all": "All Moods",
|
||||
"custom": "Custom Moods"
|
||||
},
|
||||
"sequences": {
|
||||
"title": "Sequences",
|
||||
"subtitle": "Play multiple moods in sequence",
|
||||
"moods": "moods",
|
||||
"empty": "No Sequences Yet",
|
||||
"emptyDescription": "Create a sequence by chaining multiple moods together."
|
||||
},
|
||||
"mood": {
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"addToFavorites": "Add to Favorites",
|
||||
"removeFromFavorites": "Remove from Favorites",
|
||||
"animation": "Animation",
|
||||
"colors": "Colors",
|
||||
"startTimer": "Start",
|
||||
"stopTimer": "Stop Timer",
|
||||
"timerRunning": "Timer running",
|
||||
"stop": "Stop"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"animationSpeed": "Animation Speed",
|
||||
"slow": "Slow",
|
||||
"normal": "Normal",
|
||||
"fast": "Fast",
|
||||
"brightness": "Brightness",
|
||||
"autoTimer": "Auto Timer",
|
||||
"autoTimerOff": "Off",
|
||||
"autoTimerMinutes": "{minutes} minutes",
|
||||
"autoMoodSwitch": "Auto Mood Switch",
|
||||
"autoMoodSwitchInterval": "Switch Interval",
|
||||
"reset": "Reset",
|
||||
"resetConfirm": "Reset all settings?"
|
||||
},
|
||||
"createMood": {
|
||||
"title": "Create Mood",
|
||||
"editTitle": "Edit Mood",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Enter mood name...",
|
||||
"colors": "Colors",
|
||||
"addColor": "Add Color",
|
||||
"animation": "Animation Type",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"create": "Create"
|
||||
},
|
||||
"moodsPage": {
|
||||
"page_title_html": "Moods - Moodlit - Mana",
|
||||
"title": "Moods",
|
||||
"action_close": "Close",
|
||||
"action_new_mood": "+ New mood",
|
||||
"label_name": "Name",
|
||||
"placeholder_name": "My mood",
|
||||
"label_animation": "Animation",
|
||||
"label_colors": "Colors",
|
||||
"toast_created": "\"{name}\" created",
|
||||
"toast_default_protected": "Default moods can't be deleted",
|
||||
"toast_deleted": "Deleted"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Moodlit",
|
||||
"tagline": "Iluminación ambiental y estados de ánimo"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"moods": "Moods",
|
||||
"sequences": "Secuencias",
|
||||
"settings": "Ajustes",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"home": {
|
||||
"title": "Tus Moods",
|
||||
"subtitle": "Elige una iluminación ambiental",
|
||||
"sequences": "Secuencias",
|
||||
"sequencesDescription": "Encadena varios moods en una secuencia",
|
||||
"favorites": "Favoritos",
|
||||
"all": "Todos los Moods",
|
||||
"custom": "Moods personalizados"
|
||||
},
|
||||
"sequences": {
|
||||
"title": "Secuencias",
|
||||
"subtitle": "Reproduce varios moods en secuencia",
|
||||
"moods": "moods",
|
||||
"empty": "Aún no hay secuencias",
|
||||
"emptyDescription": "Crea una secuencia encadenando varios moods."
|
||||
},
|
||||
"mood": {
|
||||
"play": "Reproducir",
|
||||
"pause": "Pausar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"addToFavorites": "Añadir a favoritos",
|
||||
"removeFromFavorites": "Quitar de favoritos",
|
||||
"animation": "Animación",
|
||||
"colors": "Colores",
|
||||
"startTimer": "Iniciar",
|
||||
"stopTimer": "Detener temporizador",
|
||||
"timerRunning": "Temporizador activo",
|
||||
"stop": "Detener"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
"animationSpeed": "Velocidad de animación",
|
||||
"slow": "Lenta",
|
||||
"normal": "Normal",
|
||||
"fast": "Rápida",
|
||||
"brightness": "Brillo",
|
||||
"autoTimer": "Temporizador automático",
|
||||
"autoTimerOff": "Desactivado",
|
||||
"autoTimerMinutes": "{minutes} minutos",
|
||||
"autoMoodSwitch": "Cambio automático de mood",
|
||||
"autoMoodSwitchInterval": "Intervalo de cambio",
|
||||
"reset": "Restablecer",
|
||||
"resetConfirm": "¿Restablecer todos los ajustes?"
|
||||
},
|
||||
"createMood": {
|
||||
"title": "Crear Mood",
|
||||
"editTitle": "Editar Mood",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "Ingresa un nombre...",
|
||||
"colors": "Colores",
|
||||
"addColor": "Añadir color",
|
||||
"animation": "Tipo de animación",
|
||||
"preview": "Vista previa"
|
||||
},
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"confirm": "Confirmar",
|
||||
"loading": "Cargando...",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
"create": "Crear"
|
||||
},
|
||||
"moodsPage": {
|
||||
"page_title_html": "Moods - Moodlit - Mana",
|
||||
"title": "Moods",
|
||||
"action_close": "Cerrar",
|
||||
"action_new_mood": "+ Nuevo mood",
|
||||
"label_name": "Nombre",
|
||||
"placeholder_name": "Mi mood",
|
||||
"label_animation": "Animación",
|
||||
"label_colors": "Colores",
|
||||
"toast_created": "«{name}» creado",
|
||||
"toast_default_protected": "Los moods predeterminados no se pueden eliminar",
|
||||
"toast_deleted": "Eliminado"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Moodlit",
|
||||
"tagline": "Éclairage ambiant et ambiances"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"moods": "Ambiances",
|
||||
"sequences": "Séquences",
|
||||
"settings": "Paramètres",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"home": {
|
||||
"title": "Vos ambiances",
|
||||
"subtitle": "Choisissez une ambiance lumineuse",
|
||||
"sequences": "Séquences",
|
||||
"sequencesDescription": "Enchaînez plusieurs ambiances en une séquence",
|
||||
"favorites": "Favoris",
|
||||
"all": "Toutes les ambiances",
|
||||
"custom": "Ambiances personnalisées"
|
||||
},
|
||||
"sequences": {
|
||||
"title": "Séquences",
|
||||
"subtitle": "Jouez plusieurs ambiances à la suite",
|
||||
"moods": "ambiances",
|
||||
"empty": "Pas encore de séquences",
|
||||
"emptyDescription": "Créez une séquence en enchaînant plusieurs ambiances."
|
||||
},
|
||||
"mood": {
|
||||
"play": "Lire",
|
||||
"pause": "Pause",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"addToFavorites": "Ajouter aux favoris",
|
||||
"removeFromFavorites": "Retirer des favoris",
|
||||
"animation": "Animation",
|
||||
"colors": "Couleurs",
|
||||
"startTimer": "Démarrer",
|
||||
"stopTimer": "Arrêter le minuteur",
|
||||
"timerRunning": "Minuteur en cours",
|
||||
"stop": "Arrêter"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"animationSpeed": "Vitesse d'animation",
|
||||
"slow": "Lente",
|
||||
"normal": "Normale",
|
||||
"fast": "Rapide",
|
||||
"brightness": "Luminosité",
|
||||
"autoTimer": "Minuteur automatique",
|
||||
"autoTimerOff": "Désactivé",
|
||||
"autoTimerMinutes": "{minutes} minutes",
|
||||
"autoMoodSwitch": "Changement automatique d'ambiance",
|
||||
"autoMoodSwitchInterval": "Intervalle de changement",
|
||||
"reset": "Réinitialiser",
|
||||
"resetConfirm": "Réinitialiser tous les paramètres ?"
|
||||
},
|
||||
"createMood": {
|
||||
"title": "Créer une ambiance",
|
||||
"editTitle": "Modifier l'ambiance",
|
||||
"name": "Nom",
|
||||
"namePlaceholder": "Saisir un nom...",
|
||||
"colors": "Couleurs",
|
||||
"addColor": "Ajouter une couleur",
|
||||
"animation": "Type d'animation",
|
||||
"preview": "Aperçu"
|
||||
},
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"confirm": "Confirmer",
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"create": "Créer"
|
||||
},
|
||||
"moodsPage": {
|
||||
"page_title_html": "Moods - Moodlit - Mana",
|
||||
"title": "Moods",
|
||||
"action_close": "Fermer",
|
||||
"action_new_mood": "+ Nouveau mood",
|
||||
"label_name": "Nom",
|
||||
"placeholder_name": "Mon mood",
|
||||
"label_animation": "Animation",
|
||||
"label_colors": "Couleurs",
|
||||
"toast_created": "« {name} » créé",
|
||||
"toast_default_protected": "Les moods par défaut ne peuvent pas être supprimés",
|
||||
"toast_deleted": "Supprimé"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Moodlit",
|
||||
"tagline": "Illuminazione ambientale e atmosfere"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"moods": "Atmosfere",
|
||||
"sequences": "Sequenze",
|
||||
"settings": "Impostazioni",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"home": {
|
||||
"title": "Le tue atmosfere",
|
||||
"subtitle": "Scegli un'atmosfera luminosa",
|
||||
"sequences": "Sequenze",
|
||||
"sequencesDescription": "Concatena più atmosfere in una sequenza",
|
||||
"favorites": "Preferiti",
|
||||
"all": "Tutte le atmosfere",
|
||||
"custom": "Atmosfere personalizzate"
|
||||
},
|
||||
"sequences": {
|
||||
"title": "Sequenze",
|
||||
"subtitle": "Riproduci più atmosfere in sequenza",
|
||||
"moods": "atmosfere",
|
||||
"empty": "Nessuna sequenza ancora",
|
||||
"emptyDescription": "Crea una sequenza concatenando più atmosfere."
|
||||
},
|
||||
"mood": {
|
||||
"play": "Riproduci",
|
||||
"pause": "Pausa",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"addToFavorites": "Aggiungi ai preferiti",
|
||||
"removeFromFavorites": "Rimuovi dai preferiti",
|
||||
"animation": "Animazione",
|
||||
"colors": "Colori",
|
||||
"startTimer": "Avvia",
|
||||
"stopTimer": "Ferma timer",
|
||||
"timerRunning": "Timer in corso",
|
||||
"stop": "Ferma"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"animationSpeed": "Velocità animazione",
|
||||
"slow": "Lenta",
|
||||
"normal": "Normale",
|
||||
"fast": "Veloce",
|
||||
"brightness": "Luminosità",
|
||||
"autoTimer": "Timer automatico",
|
||||
"autoTimerOff": "Disattivato",
|
||||
"autoTimerMinutes": "{minutes} minuti",
|
||||
"autoMoodSwitch": "Cambio automatico atmosfera",
|
||||
"autoMoodSwitchInterval": "Intervallo di cambio",
|
||||
"reset": "Ripristina",
|
||||
"resetConfirm": "Ripristinare tutte le impostazioni?"
|
||||
},
|
||||
"createMood": {
|
||||
"title": "Crea atmosfera",
|
||||
"editTitle": "Modifica atmosfera",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "Inserisci un nome...",
|
||||
"colors": "Colori",
|
||||
"addColor": "Aggiungi colore",
|
||||
"animation": "Tipo di animazione",
|
||||
"preview": "Anteprima"
|
||||
},
|
||||
"common": {
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"confirm": "Conferma",
|
||||
"loading": "Caricamento...",
|
||||
"error": "Errore",
|
||||
"success": "Successo",
|
||||
"create": "Crea"
|
||||
},
|
||||
"moodsPage": {
|
||||
"page_title_html": "Moods - Moodlit - Mana",
|
||||
"title": "Moods",
|
||||
"action_close": "Chiudi",
|
||||
"action_new_mood": "+ Nuovo mood",
|
||||
"label_name": "Nome",
|
||||
"placeholder_name": "Il mio mood",
|
||||
"label_animation": "Animazione",
|
||||
"label_colors": "Colori",
|
||||
"toast_created": "«{name}» creato",
|
||||
"toast_default_protected": "I mood predefiniti non possono essere eliminati",
|
||||
"toast_deleted": "Eliminato"
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
"categories": {
|
||||
"home": "Zuhause",
|
||||
"work": "Arbeit",
|
||||
"food": "Essen",
|
||||
"shopping": "Einkauf",
|
||||
"transit": "Transit",
|
||||
"leisure": "Freizeit",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"categories": {
|
||||
"home": "Home",
|
||||
"work": "Work",
|
||||
"food": "Food",
|
||||
"shopping": "Shopping",
|
||||
"transit": "Transit",
|
||||
"leisure": "Leisure",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"categories": {
|
||||
"home": "Casa",
|
||||
"work": "Trabajo",
|
||||
"food": "Comida",
|
||||
"shopping": "Compras",
|
||||
"transit": "Tránsito",
|
||||
"leisure": "Ocio",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"categories": {
|
||||
"home": "Maison",
|
||||
"work": "Travail",
|
||||
"food": "Repas",
|
||||
"shopping": "Achats",
|
||||
"transit": "Transit",
|
||||
"leisure": "Loisirs",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"categories": {
|
||||
"home": "Casa",
|
||||
"work": "Lavoro",
|
||||
"food": "Cibo",
|
||||
"shopping": "Shopping",
|
||||
"transit": "Transito",
|
||||
"leisure": "Tempo libero",
|
||||
|
|
|
|||
|
|
@ -1,257 +0,0 @@
|
|||
{
|
||||
"categories": {
|
||||
"all": "Alle",
|
||||
"top": "Oberteile",
|
||||
"bottom": "Hosen",
|
||||
"dress": "Kleider",
|
||||
"outerwear": "Jacken",
|
||||
"shoes": "Schuhe",
|
||||
"bag": "Taschen",
|
||||
"accessory": "Accessoires",
|
||||
"glasses": "Brillen",
|
||||
"jewelry": "Schmuck",
|
||||
"hat": "Kopfbedeckung",
|
||||
"other": "Sonstiges"
|
||||
},
|
||||
"categories_singular": {
|
||||
"top": "Oberteil",
|
||||
"bottom": "Hose",
|
||||
"dress": "Kleid",
|
||||
"outerwear": "Jacke",
|
||||
"shoes": "Schuh",
|
||||
"bag": "Tasche",
|
||||
"accessory": "Accessoire",
|
||||
"glasses": "Brille",
|
||||
"jewelry": "Schmuck",
|
||||
"hat": "Kopfbedeckung",
|
||||
"other": "Item"
|
||||
},
|
||||
"occasions": {
|
||||
"casual": "Casual",
|
||||
"work": "Arbeit",
|
||||
"formal": "Festlich",
|
||||
"workout": "Sport",
|
||||
"date": "Date",
|
||||
"travel": "Reise",
|
||||
"event": "Event",
|
||||
"sleep": "Schlafanzug",
|
||||
"other": "Sonstiges"
|
||||
},
|
||||
"seasons": {
|
||||
"spring": "Frühling",
|
||||
"summer": "Sommer",
|
||||
"autumn": "Herbst",
|
||||
"winter": "Winter"
|
||||
},
|
||||
"piece_singular": "Stück",
|
||||
"piece_plural": "Stücke",
|
||||
"upload_failed": "Upload fehlgeschlagen",
|
||||
"list_view": {
|
||||
"aria_tabs": "Ansicht wechseln",
|
||||
"tab_garments": "Kleidung",
|
||||
"tab_outfits": "Outfits",
|
||||
"face_saved_title": "Gesichtsbild gespeichert",
|
||||
"face_saved_hint": "Perfekt — als nächstes lädst du unten dein erstes Kleidungsstück hoch.",
|
||||
"dismiss": "Schließen",
|
||||
"face_prompt_title": "Lade ein Gesichtsbild hoch",
|
||||
"face_prompt_desc": "Wir brauchen dich auf Bild, damit Try-On Kleidung an dir visualisieren kann. Das Bild bleibt lokal und wird nur für deine eigenen Generierungen genutzt.",
|
||||
"face_uploading_label": "Wird hochgeladen…",
|
||||
"face_upload_label": "Gesichtsbild hochladen",
|
||||
"face_upload_hint": "Kopf + Schulter, möglichst neutrale Beleuchtung",
|
||||
"face_uploading_chip": "Lade…"
|
||||
},
|
||||
"grid_view": {
|
||||
"upload_label_all": "Kleidungsstück hochladen",
|
||||
"upload_label_for_category": "{category} hochladen",
|
||||
"upload_hint": "Foto frontal, heller Hintergrund — bessere Try-On-Ergebnisse",
|
||||
"empty_title": "Noch nichts im Schrank.",
|
||||
"empty_hint": "Zieh ein Foto in die Zone oben — oder klick sie an, um eins auszuwählen.",
|
||||
"no_entries_under": "Keine Einträge unter {category}.",
|
||||
"space_footer": "Dieser Schrank gehört zu {name} — Uploads landen nur hier, nicht in deinem persönlichen Schrank."
|
||||
},
|
||||
"outfits_view": {
|
||||
"title": "Outfits",
|
||||
"count_singular": "Zusammenstellung",
|
||||
"count_plural": "Zusammenstellungen",
|
||||
"action_new": "Neues Outfit",
|
||||
"empty_title": "Noch keine Outfits.",
|
||||
"empty_no_garments": "Füge zuerst ein paar Kleidungsstücke im Tab \"Kleidung\" hinzu — danach lassen sie sich hier zu Outfits kombinieren.",
|
||||
"empty_with_garments": "Kombiniere deine Kleidungsstücke zu Looks, die du dann mit KI an dir selbst anprobieren kannst.",
|
||||
"action_compose_first": "Erstes Outfit komponieren"
|
||||
},
|
||||
"detail_garment": {
|
||||
"loading": "Lädt…",
|
||||
"not_found_title": "Nicht gefunden.",
|
||||
"not_found_desc": "Das Kleidungsstück wurde gelöscht oder gehört zu einem anderen Space.",
|
||||
"action_enlarge": "Foto vergrößern",
|
||||
"action_edit": "Bearbeiten",
|
||||
"label_brand": "Marke",
|
||||
"label_color": "Farbe",
|
||||
"label_size": "Größe",
|
||||
"label_material": "Material",
|
||||
"label_price": "Preis",
|
||||
"label_wear_count": "Getragen",
|
||||
"wear_count_value": "{count}×",
|
||||
"last_worn_suffix": " · zuletzt {date}",
|
||||
"action_comic": "Als Comic-Character",
|
||||
"action_comic_title": "Aus diesem Kleidungsstück einen Comic-Character generieren",
|
||||
"action_marking": "Gespeichert…",
|
||||
"action_mark_worn": "Heute getragen",
|
||||
"action_unarchive": "Wieder aktiv setzen",
|
||||
"action_archive": "Archivieren",
|
||||
"action_delete": "Löschen",
|
||||
"section_try_ons": "Anproben · {count}",
|
||||
"section_outfits": "In Outfits · {count}",
|
||||
"no_try_on_yet": "Noch keine Anprobe",
|
||||
"action_open_picture": "In Picture öffnen",
|
||||
"confirm_delete": "\"{name}\" wirklich löschen?"
|
||||
},
|
||||
"detail_outfit": {
|
||||
"back": "Zurück zum Kleiderschrank",
|
||||
"breadcrumb": "Kleiderschrank · Outfits",
|
||||
"loading": "Lädt…",
|
||||
"not_found_title": "Outfit nicht gefunden.",
|
||||
"not_found_desc": "Gelöscht oder in einem anderen Space.",
|
||||
"try_on_preview_alt": "Try-On Vorschau",
|
||||
"no_garments": "Keine Kleidungsstücke",
|
||||
"action_comic": "Als Comic-Character",
|
||||
"action_comic_title": "Aus diesem Outfit einen Comic-Character generieren",
|
||||
"try_on_history": "Try-On Verlauf",
|
||||
"action_unfavorite": "Favorit entfernen",
|
||||
"action_favorite": "Als Favorit markieren",
|
||||
"action_edit": "Bearbeiten",
|
||||
"label_visibility": "Sichtbarkeit",
|
||||
"section_composition": "Zusammenstellung",
|
||||
"composition_missing": "Referenzierte Kleidungsstücke wurden entfernt oder gehören zu einem anderen Space.",
|
||||
"action_unarchive": "Wieder aktiv",
|
||||
"action_archive": "Archivieren",
|
||||
"action_delete": "Löschen",
|
||||
"confirm_delete": "Outfit \"{name}\" wirklich löschen?"
|
||||
},
|
||||
"garment_form": {
|
||||
"err_name_required": "Name darf nicht leer sein",
|
||||
"err_save_failed": "Speichern fehlgeschlagen",
|
||||
"label_name": "Name",
|
||||
"placeholder_name": "z.B. Blau-weiß gestreiftes Hemd",
|
||||
"label_category": "Kategorie",
|
||||
"label_brand": "Marke",
|
||||
"placeholder_brand": "z.B. Uniqlo",
|
||||
"label_color": "Farbe",
|
||||
"placeholder_color": "z.B. navy",
|
||||
"label_size": "Größe",
|
||||
"placeholder_size": "z.B. M oder 42",
|
||||
"label_material": "Material",
|
||||
"placeholder_material": "z.B. Baumwolle",
|
||||
"label_tags": "Tags",
|
||||
"tags_hint": "(komma-getrennt)",
|
||||
"placeholder_tags": "formal, sommer, lieblingsstück",
|
||||
"label_price": "Preis",
|
||||
"aria_currency": "Währung",
|
||||
"label_notes": "Notizen",
|
||||
"placeholder_notes": "Anlass, Tragevorschriften, …",
|
||||
"action_saving": "Speichere…",
|
||||
"action_save": "Speichern",
|
||||
"action_cancel": "Abbrechen"
|
||||
},
|
||||
"composer": {
|
||||
"err_name_required": "Gib dem Outfit einen Namen.",
|
||||
"err_no_garments": "Wähle mindestens ein Kleidungsstück aus.",
|
||||
"err_save_failed": "Speichern fehlgeschlagen",
|
||||
"section_library": "Kleiderschrank",
|
||||
"available_singular": "{count} Stück verfügbar",
|
||||
"available_plural": "{count} Stücke verfügbar",
|
||||
"empty_title": "Nichts zum Kombinieren.",
|
||||
"empty_hint_prefix": "Lade zuerst ein paar Kleidungsstücke im Tab",
|
||||
"tab_garments_link": "Kleidung",
|
||||
"empty_hint_suffix": "hoch.",
|
||||
"label_name": "Name",
|
||||
"placeholder_name": "z.B. Bürooutfit Juni",
|
||||
"label_description": "Beschreibung",
|
||||
"placeholder_description": "Für welchen Anlass? Besonderheiten?",
|
||||
"label_occasion": "Anlass",
|
||||
"no_occasion": "— kein Anlass —",
|
||||
"label_seasons": "Jahreszeit",
|
||||
"label_tags": "Tags",
|
||||
"tags_hint": "(komma-getrennt)",
|
||||
"placeholder_tags": "minimal, layering, meeting",
|
||||
"section_composition": "Zusammenstellung",
|
||||
"composition_count_singular": "· {count} Stück",
|
||||
"composition_count_plural": "· {count} Stücke",
|
||||
"composition_empty": "Klicke links auf Kleidungsstücke, um sie dem Outfit hinzuzufügen.",
|
||||
"action_remove": "Aus Outfit entfernen",
|
||||
"action_saving": "Speichere…",
|
||||
"action_save_edit": "Änderungen speichern",
|
||||
"action_save_new": "Outfit anlegen",
|
||||
"action_cancel": "Abbrechen"
|
||||
},
|
||||
"try_on_garment": {
|
||||
"err_failed": "Try-On fehlgeschlagen",
|
||||
"err_upload": "Upload fehlgeschlagen",
|
||||
"no_photo": "Lade erst ein Foto hoch, um dieses Stück an dir zu visualisieren.",
|
||||
"refs_title": "Für Solo-Try-On brauchen wir dich auf Bild.",
|
||||
"refs_accessory": "Ein Gesichtsbild reicht — das Stück wird darauf montiert.",
|
||||
"refs_full": "Ein Gesichts- und ein Ganzkörperbild. Beide werden nur für deine eigenen Generierungen genutzt.",
|
||||
"upload_face": "Gesichtsbild hochladen",
|
||||
"upload_body": "Ganzkörperbild hochladen",
|
||||
"face_hint": "Kopf + Schulter, möglichst neutrale Beleuchtung",
|
||||
"body_hint": "Stehend, freier Hintergrund, gut erkennbare Haltung",
|
||||
"refs_more_prefix": "Weitere Referenzen oder AI-Opt-ins pro Bild:",
|
||||
"refs_link": "Meine Bilder",
|
||||
"rendering": "Rendere…",
|
||||
"cta": "An mir anprobieren",
|
||||
"credits": "{count} Credits",
|
||||
"accessory_hint": "Accessoire-Modus — nur das Gesicht wird gerendert (spart Credits).",
|
||||
"space_hint_prefix": "Try-On nutzt deine Referenzbilder aus diesem Space",
|
||||
"space_hint_suffix": ", nicht aus Persönlich.",
|
||||
"result_label": "Ergebnis",
|
||||
"try_on_alt": "Try-On",
|
||||
"result_hint_prefix": "Gefunden in der",
|
||||
"picture_gallery_link": "Picture-Galerie",
|
||||
"result_hint_suffix": "als normale Generierung."
|
||||
},
|
||||
"try_on_outfit": {
|
||||
"err_failed": "Try-On fehlgeschlagen",
|
||||
"err_upload": "Upload fehlgeschlagen",
|
||||
"refs_title": "Für Try-On brauchen wir dich auf Bild.",
|
||||
"refs_accessory": "Ein Gesichtsbild reicht — der Rest bleibt wie auf deinem Foto.",
|
||||
"refs_full": "Ein Gesichts- und ein Ganzkörperbild. Beide werden nur für deine eigenen Generierungen genutzt.",
|
||||
"upload_face": "Gesichtsbild hochladen",
|
||||
"upload_body": "Ganzkörperbild hochladen",
|
||||
"face_hint": "Kopf + Schulter, möglichst neutrale Beleuchtung",
|
||||
"body_hint": "Stehend, freier Hintergrund, gut erkennbare Haltung",
|
||||
"refs_more_prefix": "Weitere Referenzen oder AI-Opt-ins pro Bild:",
|
||||
"refs_link": "Meine Bilder",
|
||||
"rendering": "Rendere…",
|
||||
"cta": "Anprobieren",
|
||||
"credits": "{count} Credits",
|
||||
"accessory_hint": "Accessoire-Modus — nur das Gesicht wird gerendert (spart Credits).",
|
||||
"many_garments_hint": "Mit {count} Kleidungsstücken ist der Referenz-Slot knapp — ältere Items werden evtl. nicht mitgezogen.",
|
||||
"space_hint_prefix": "Try-On nutzt deine Referenzbilder aus diesem Space",
|
||||
"space_hint_suffix": ", nicht aus Persönlich.",
|
||||
"family_hint": "Kinder-Outfits werden trotzdem auf dein Gesicht gerendert.",
|
||||
"empty_garments": "Füge mindestens ein {category} hinzu, um Try-On zu aktivieren."
|
||||
},
|
||||
"model_picker": {
|
||||
"legend": "Modell",
|
||||
"option_openai_label": "OpenAI",
|
||||
"option_openai_hint": "GPT-image · Standard",
|
||||
"option_pro_label": "Nano Banana Pro",
|
||||
"option_pro_hint": "Google · hohe Konsistenz",
|
||||
"option_flash_label": "Nano Banana 2",
|
||||
"option_flash_hint": "Google · neuestes · günstig"
|
||||
},
|
||||
"garment_card": {
|
||||
"wear_count_title": "{count}× getragen"
|
||||
},
|
||||
"outfit_card": {
|
||||
"try_on_badge": "Try-On",
|
||||
"try_on_preview_title": "Try-On Vorschau",
|
||||
"empty": "Leer",
|
||||
"favorite": "Favorit"
|
||||
},
|
||||
"compose": {
|
||||
"title_edit": "Outfit bearbeiten",
|
||||
"title_new": "Neues Outfit",
|
||||
"back": "Zurück"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
{
|
||||
"categories": {
|
||||
"all": "All",
|
||||
"top": "Tops",
|
||||
"bottom": "Bottoms",
|
||||
"dress": "Dresses",
|
||||
"outerwear": "Jackets",
|
||||
"shoes": "Shoes",
|
||||
"bag": "Bags",
|
||||
"accessory": "Accessories",
|
||||
"glasses": "Glasses",
|
||||
"jewelry": "Jewelry",
|
||||
"hat": "Hats",
|
||||
"other": "Other"
|
||||
},
|
||||
"categories_singular": {
|
||||
"top": "Top",
|
||||
"bottom": "Bottom",
|
||||
"dress": "Dress",
|
||||
"outerwear": "Jacket",
|
||||
"shoes": "Shoe",
|
||||
"bag": "Bag",
|
||||
"accessory": "Accessory",
|
||||
"glasses": "Glasses",
|
||||
"jewelry": "Jewelry",
|
||||
"hat": "Hat",
|
||||
"other": "Item"
|
||||
},
|
||||
"occasions": {
|
||||
"casual": "Casual",
|
||||
"work": "Work",
|
||||
"formal": "Formal",
|
||||
"workout": "Workout",
|
||||
"date": "Date",
|
||||
"travel": "Travel",
|
||||
"event": "Event",
|
||||
"sleep": "Sleepwear",
|
||||
"other": "Other"
|
||||
},
|
||||
"seasons": {
|
||||
"spring": "Spring",
|
||||
"summer": "Summer",
|
||||
"autumn": "Autumn",
|
||||
"winter": "Winter"
|
||||
},
|
||||
"piece_singular": "piece",
|
||||
"piece_plural": "pieces",
|
||||
"upload_failed": "Upload failed",
|
||||
"list_view": {
|
||||
"aria_tabs": "Switch view",
|
||||
"tab_garments": "Clothing",
|
||||
"tab_outfits": "Outfits",
|
||||
"face_saved_title": "Face photo saved",
|
||||
"face_saved_hint": "Perfect — next, upload your first piece of clothing below.",
|
||||
"dismiss": "Close",
|
||||
"face_prompt_title": "Upload a face photo",
|
||||
"face_prompt_desc": "We need you in a photo so Try-On can visualize clothing on you. The image stays local and is only used for your own generations.",
|
||||
"face_uploading_label": "Uploading…",
|
||||
"face_upload_label": "Upload face photo",
|
||||
"face_upload_hint": "Head + shoulders, neutral lighting if possible",
|
||||
"face_uploading_chip": "Loading…"
|
||||
},
|
||||
"grid_view": {
|
||||
"upload_label_all": "Upload garment",
|
||||
"upload_label_for_category": "Upload {category}",
|
||||
"upload_hint": "Front-on photo, bright background — better Try-On results",
|
||||
"empty_title": "Nothing in the wardrobe yet.",
|
||||
"empty_hint": "Drag a photo into the zone above — or click it to pick one.",
|
||||
"no_entries_under": "No items under {category}.",
|
||||
"space_footer": "This wardrobe belongs to {name} — uploads only land here, not in your personal wardrobe."
|
||||
},
|
||||
"outfits_view": {
|
||||
"title": "Outfits",
|
||||
"count_singular": "outfit",
|
||||
"count_plural": "outfits",
|
||||
"action_new": "New outfit",
|
||||
"empty_title": "No outfits yet.",
|
||||
"empty_no_garments": "First add a few pieces of clothing in the \"Clothing\" tab — then you can combine them into outfits here.",
|
||||
"empty_with_garments": "Combine your clothing pieces into looks that you can then try on yourself with AI.",
|
||||
"action_compose_first": "Compose first outfit"
|
||||
},
|
||||
"detail_garment": {
|
||||
"loading": "Loading…",
|
||||
"not_found_title": "Not found.",
|
||||
"not_found_desc": "The garment was deleted or belongs to another space.",
|
||||
"action_enlarge": "Enlarge photo",
|
||||
"action_edit": "Edit",
|
||||
"label_brand": "Brand",
|
||||
"label_color": "Color",
|
||||
"label_size": "Size",
|
||||
"label_material": "Material",
|
||||
"label_price": "Price",
|
||||
"label_wear_count": "Worn",
|
||||
"wear_count_value": "{count}×",
|
||||
"last_worn_suffix": " · last on {date}",
|
||||
"action_comic": "As comic character",
|
||||
"action_comic_title": "Generate a comic character from this garment",
|
||||
"action_marking": "Saving…",
|
||||
"action_mark_worn": "Worn today",
|
||||
"action_unarchive": "Set active again",
|
||||
"action_archive": "Archive",
|
||||
"action_delete": "Delete",
|
||||
"section_try_ons": "Try-Ons · {count}",
|
||||
"section_outfits": "In outfits · {count}",
|
||||
"no_try_on_yet": "No try-on yet",
|
||||
"action_open_picture": "Open in Picture",
|
||||
"confirm_delete": "Really delete \"{name}\"?"
|
||||
},
|
||||
"detail_outfit": {
|
||||
"back": "Back to wardrobe",
|
||||
"breadcrumb": "Wardrobe · Outfits",
|
||||
"loading": "Loading…",
|
||||
"not_found_title": "Outfit not found.",
|
||||
"not_found_desc": "Deleted or in another space.",
|
||||
"try_on_preview_alt": "Try-On preview",
|
||||
"no_garments": "No garments",
|
||||
"action_comic": "As comic character",
|
||||
"action_comic_title": "Generate a comic character from this outfit",
|
||||
"try_on_history": "Try-On history",
|
||||
"action_unfavorite": "Remove favorite",
|
||||
"action_favorite": "Mark as favorite",
|
||||
"action_edit": "Edit",
|
||||
"label_visibility": "Visibility",
|
||||
"section_composition": "Composition",
|
||||
"composition_missing": "Referenced garments were removed or belong to another space.",
|
||||
"action_unarchive": "Set active",
|
||||
"action_archive": "Archive",
|
||||
"action_delete": "Delete",
|
||||
"confirm_delete": "Really delete outfit \"{name}\"?"
|
||||
},
|
||||
"garment_form": {
|
||||
"err_name_required": "Name cannot be empty",
|
||||
"err_save_failed": "Save failed",
|
||||
"label_name": "Name",
|
||||
"placeholder_name": "e.g. Blue and white striped shirt",
|
||||
"label_category": "Category",
|
||||
"label_brand": "Brand",
|
||||
"placeholder_brand": "e.g. Uniqlo",
|
||||
"label_color": "Color",
|
||||
"placeholder_color": "e.g. navy",
|
||||
"label_size": "Size",
|
||||
"placeholder_size": "e.g. M or 42",
|
||||
"label_material": "Material",
|
||||
"placeholder_material": "e.g. cotton",
|
||||
"label_tags": "Tags",
|
||||
"tags_hint": "(comma-separated)",
|
||||
"placeholder_tags": "formal, summer, favorite",
|
||||
"label_price": "Price",
|
||||
"aria_currency": "Currency",
|
||||
"label_notes": "Notes",
|
||||
"placeholder_notes": "Occasion, care instructions, …",
|
||||
"action_saving": "Saving…",
|
||||
"action_save": "Save",
|
||||
"action_cancel": "Cancel"
|
||||
},
|
||||
"composer": {
|
||||
"err_name_required": "Give the outfit a name.",
|
||||
"err_no_garments": "Pick at least one garment.",
|
||||
"err_save_failed": "Save failed",
|
||||
"section_library": "Wardrobe",
|
||||
"available_singular": "{count} piece available",
|
||||
"available_plural": "{count} pieces available",
|
||||
"empty_title": "Nothing to combine.",
|
||||
"empty_hint_prefix": "First upload a few garments in the",
|
||||
"tab_garments_link": "Clothing",
|
||||
"empty_hint_suffix": "tab.",
|
||||
"label_name": "Name",
|
||||
"placeholder_name": "e.g. Office outfit June",
|
||||
"label_description": "Description",
|
||||
"placeholder_description": "For what occasion? Anything special?",
|
||||
"label_occasion": "Occasion",
|
||||
"no_occasion": "— no occasion —",
|
||||
"label_seasons": "Season",
|
||||
"label_tags": "Tags",
|
||||
"tags_hint": "(comma-separated)",
|
||||
"placeholder_tags": "minimal, layering, meeting",
|
||||
"section_composition": "Composition",
|
||||
"composition_count_singular": "· {count} piece",
|
||||
"composition_count_plural": "· {count} pieces",
|
||||
"composition_empty": "Click garments on the left to add them to the outfit.",
|
||||
"action_remove": "Remove from outfit",
|
||||
"action_saving": "Saving…",
|
||||
"action_save_edit": "Save changes",
|
||||
"action_save_new": "Create outfit",
|
||||
"action_cancel": "Cancel"
|
||||
},
|
||||
"try_on_garment": {
|
||||
"err_failed": "Try-On failed",
|
||||
"err_upload": "Upload failed",
|
||||
"no_photo": "Upload a photo first to visualize this piece on yourself.",
|
||||
"refs_title": "We need you in a photo for solo try-on.",
|
||||
"refs_accessory": "A face photo is enough — the piece is mounted onto it.",
|
||||
"refs_full": "A face and a full-body photo. Both are only used for your own generations.",
|
||||
"upload_face": "Upload face photo",
|
||||
"upload_body": "Upload full-body photo",
|
||||
"face_hint": "Head + shoulders, neutral lighting if possible",
|
||||
"body_hint": "Standing, free background, posture clearly visible",
|
||||
"refs_more_prefix": "More references or per-image AI opt-ins:",
|
||||
"refs_link": "My Images",
|
||||
"rendering": "Rendering…",
|
||||
"cta": "Try on me",
|
||||
"credits": "{count} credits",
|
||||
"accessory_hint": "Accessory mode — only the face is rendered (saves credits).",
|
||||
"space_hint_prefix": "Try-On uses your reference images from this space",
|
||||
"space_hint_suffix": ", not from Personal.",
|
||||
"result_label": "Result",
|
||||
"try_on_alt": "Try-On",
|
||||
"result_hint_prefix": "Found in the",
|
||||
"picture_gallery_link": "Picture gallery",
|
||||
"result_hint_suffix": "as a regular generation."
|
||||
},
|
||||
"try_on_outfit": {
|
||||
"err_failed": "Try-On failed",
|
||||
"err_upload": "Upload failed",
|
||||
"refs_title": "We need you in a photo for try-on.",
|
||||
"refs_accessory": "A face photo is enough — the rest stays as in your photo.",
|
||||
"refs_full": "A face and a full-body photo. Both are only used for your own generations.",
|
||||
"upload_face": "Upload face photo",
|
||||
"upload_body": "Upload full-body photo",
|
||||
"face_hint": "Head + shoulders, neutral lighting if possible",
|
||||
"body_hint": "Standing, free background, posture clearly visible",
|
||||
"refs_more_prefix": "More references or per-image AI opt-ins:",
|
||||
"refs_link": "My Images",
|
||||
"rendering": "Rendering…",
|
||||
"cta": "Try on",
|
||||
"credits": "{count} credits",
|
||||
"accessory_hint": "Accessory mode — only the face is rendered (saves credits).",
|
||||
"many_garments_hint": "With {count} garments the reference slot is tight — older items may not be carried over.",
|
||||
"space_hint_prefix": "Try-On uses your reference images from this space",
|
||||
"space_hint_suffix": ", not from Personal.",
|
||||
"family_hint": "Kids' outfits are still rendered onto your face.",
|
||||
"empty_garments": "Add at least one {category} to enable Try-On."
|
||||
},
|
||||
"model_picker": {
|
||||
"legend": "Model",
|
||||
"option_openai_label": "OpenAI",
|
||||
"option_openai_hint": "GPT-image · Standard",
|
||||
"option_pro_label": "Nano Banana Pro",
|
||||
"option_pro_hint": "Google · high consistency",
|
||||
"option_flash_label": "Nano Banana 2",
|
||||
"option_flash_hint": "Google · newest · cheap"
|
||||
},
|
||||
"garment_card": {
|
||||
"wear_count_title": "Worn {count}×"
|
||||
},
|
||||
"outfit_card": {
|
||||
"try_on_badge": "Try-On",
|
||||
"try_on_preview_title": "Try-On preview",
|
||||
"empty": "Empty",
|
||||
"favorite": "Favorite"
|
||||
},
|
||||
"compose": {
|
||||
"title_edit": "Edit outfit",
|
||||
"title_new": "New outfit",
|
||||
"back": "Back"
|
||||
}
|
||||
}
|
||||