158 files
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "Punkte manuell eingeben",
|
||||
"activity.reason_allocated_to_pool": "Dem Pool zugewiesen: {name}",
|
||||
"activity.reason_admin_adjustment": "Manuelle Anpassung",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Strafe: {name}",
|
||||
"activity.reason_perfect_week": "Perfekter Wochenbonus!",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Wähle ein MDI-Symbol",
|
||||
"panel.settings_points_icon_label": "Punkte-Symbol",
|
||||
"panel.settings_points_name_hint": "Wie du sie nennst, z.B. Sterne oder Münzen",
|
||||
"panel.settings_quick_points_label": "Schnellbeträge für Punkte",
|
||||
"panel.settings_quick_points_hint": "Kommagetrennte Beträge für die +/−-Schaltflächen auf jeder Kinderkarte. Es werden bis zu drei verwendet.",
|
||||
"panel.settings_points_name_label": "Punktename",
|
||||
"panel.settings_retention_hint": "Wie viele Tage Erledigungsverlauf gespeichert werden",
|
||||
"panel.settings_retention_label": "Aufbewahrung",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"panel.chore_require_photo_label": "Fotonachweis verlangen",
|
||||
"panel.chore_require_photo_hint": "Erledigungen müssen von Eltern bestätigt werden; ein Beweisfoto (falls vorhanden) erscheint bei den Genehmigungen.",
|
||||
"panel.activity_view_photo": "Foto ansehen",
|
||||
"panel.adjust_add": "Hinzufügen",
|
||||
"panel.adjust_add_title": "{child} {amount} {points} hinzufügen",
|
||||
"panel.adjust_amount": "Betrag",
|
||||
"panel.adjust_custom_title": "Punkte von {child} um einen eigenen Betrag anpassen",
|
||||
"panel.adjust_dialog_title": "Punkte anpassen — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} für {child}",
|
||||
"panel.adjust_err_amount": "Gib einen Betrag zwischen 1 und 10000 ein.",
|
||||
"panel.adjust_reason": "Grund (optional)",
|
||||
"panel.adjust_remove": "Abziehen",
|
||||
"panel.adjust_remove_title": "{child} {amount} {points} abziehen",
|
||||
"panel.swap_btn": "Tausch anfragen",
|
||||
"panel.swap_dialog_title": "Tausch: {chore}",
|
||||
"panel.swap_requester": "Heutigen Dienst übergeben an",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Versäumt",
|
||||
"panel.health_count_storage": "Speicher",
|
||||
"panel.chore_icon_label": "Bild",
|
||||
"panel.chore_image_label": "Bild",
|
||||
"panel.chore_image_hint": "Ein Foto, das statt des Symbols angezeigt wird. Beim Hochladen auf 512px verkleinert.",
|
||||
"panel.chore_image_upload": "Hochladen",
|
||||
"panel.chore_image_remove": "Entfernen",
|
||||
"panel.chore_image_empty": "Kein Bild",
|
||||
"panel.chore_image_uploading": "Wird hochgeladen…",
|
||||
"panel.chore_image_failed": "Hochladen fehlgeschlagen",
|
||||
"panel.chore_image_too_large": "Dieses Bild ist zu groß (max. 2 MB).",
|
||||
"panel.chore_image_bad_type": "Wähle ein JPEG-, PNG- oder WebP-Bild.",
|
||||
"child.editor.pre_reader": "Nur-Bilder-Modus (für kleine Kinder)",
|
||||
"child.editor.pre_reader_labels": "Namen im Bildmodus anzeigen",
|
||||
"common.design.accessible": "Barrierefrei"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "points manually",
|
||||
"activity.reason_allocated_to_pool": "Allocated to pool: {name}",
|
||||
"activity.reason_admin_adjustment": "Manual adjustment",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Penalty: {name}",
|
||||
"activity.reason_perfect_week": "Perfect week bonus!",
|
||||
@@ -839,6 +840,8 @@
|
||||
"panel.settings_points_icon_hint": "Pick any MDI icon",
|
||||
"panel.settings_points_icon_label": "Points icon",
|
||||
"panel.settings_points_name_hint": "What you call them, e.g. Stars or Coins",
|
||||
"panel.settings_quick_points_label": "Quick point amounts",
|
||||
"panel.settings_quick_points_hint": "Comma-separated amounts for the + and − buttons on each child card. Up to three are used.",
|
||||
"panel.settings_points_name_label": "Points name",
|
||||
"panel.settings_retention_hint": "How many days of completion history to keep",
|
||||
"panel.settings_retention_label": "Retention",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"panel.chore_require_photo_label": "Require photo proof",
|
||||
"panel.chore_require_photo_hint": "Completions must be parent-approved; an evidence photo (if provided) shows in approvals.",
|
||||
"panel.activity_view_photo": "View photo",
|
||||
"panel.adjust_add": "Add",
|
||||
"panel.adjust_add_title": "Add {amount} {points} to {child}",
|
||||
"panel.adjust_amount": "Amount",
|
||||
"panel.adjust_custom_title": "Adjust {child}'s points by a custom amount",
|
||||
"panel.adjust_dialog_title": "Adjust points — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} for {child}",
|
||||
"panel.adjust_err_amount": "Enter an amount between 1 and 10000.",
|
||||
"panel.adjust_reason": "Reason (optional)",
|
||||
"panel.adjust_remove": "Remove",
|
||||
"panel.adjust_remove_title": "Remove {amount} {points} from {child}",
|
||||
"panel.swap_btn": "Request swap",
|
||||
"panel.swap_dialog_title": "Swap: {chore}",
|
||||
"panel.swap_requester": "Give today's turn to",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Misses",
|
||||
"panel.health_count_storage": "Storage",
|
||||
"panel.chore_icon_label": "Picture",
|
||||
"panel.chore_image_label": "Picture",
|
||||
"panel.chore_image_hint": "A photo shown instead of the icon. Resized to 512px on upload.",
|
||||
"panel.chore_image_upload": "Upload",
|
||||
"panel.chore_image_remove": "Remove",
|
||||
"panel.chore_image_empty": "No picture",
|
||||
"panel.chore_image_uploading": "Uploading…",
|
||||
"panel.chore_image_failed": "Upload failed",
|
||||
"panel.chore_image_too_large": "That image is too large (max 2 MB).",
|
||||
"panel.chore_image_bad_type": "Choose a JPEG, PNG or WebP image.",
|
||||
"child.editor.pre_reader": "Picture-only mode (for young children)",
|
||||
"child.editor.pre_reader_labels": "Show names in picture mode",
|
||||
"common.design.accessible": "Accessible"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "points manually",
|
||||
"activity.reason_allocated_to_pool": "Allocated to pool: {name}",
|
||||
"activity.reason_admin_adjustment": "Manual adjustment",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Penalty: {name}",
|
||||
"activity.reason_perfect_week": "Perfect week bonus!",
|
||||
@@ -839,6 +840,8 @@
|
||||
"panel.settings_points_icon_hint": "Pick any MDI icon",
|
||||
"panel.settings_points_icon_label": "Points icon",
|
||||
"panel.settings_points_name_hint": "What you call them, e.g. Stars or Coins",
|
||||
"panel.settings_quick_points_label": "Quick point amounts",
|
||||
"panel.settings_quick_points_hint": "Comma-separated amounts for the + and − buttons on each child card. Up to three are used.",
|
||||
"panel.settings_points_name_label": "Points name",
|
||||
"panel.settings_retention_hint": "How many days of completion history to keep",
|
||||
"panel.settings_retention_label": "Retention",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"panel.chore_require_photo_label": "Require photo proof",
|
||||
"panel.chore_require_photo_hint": "Completions must be parent-approved; an evidence photo (if provided) shows in approvals.",
|
||||
"panel.activity_view_photo": "View photo",
|
||||
"panel.adjust_add": "Add",
|
||||
"panel.adjust_add_title": "Add {amount} {points} to {child}",
|
||||
"panel.adjust_amount": "Amount",
|
||||
"panel.adjust_custom_title": "Adjust {child}'s points by a custom amount",
|
||||
"panel.adjust_dialog_title": "Adjust points — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} for {child}",
|
||||
"panel.adjust_err_amount": "Enter an amount between 1 and 10000.",
|
||||
"panel.adjust_reason": "Reason (optional)",
|
||||
"panel.adjust_remove": "Remove",
|
||||
"panel.adjust_remove_title": "Remove {amount} {points} from {child}",
|
||||
"panel.swap_btn": "Request swap",
|
||||
"panel.swap_dialog_title": "Swap: {chore}",
|
||||
"panel.swap_requester": "Give today's turn to",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Misses",
|
||||
"panel.health_count_storage": "Storage",
|
||||
"panel.chore_icon_label": "Picture",
|
||||
"panel.chore_image_label": "Picture",
|
||||
"panel.chore_image_hint": "A photo shown instead of the icon. Resized to 512px on upload.",
|
||||
"panel.chore_image_upload": "Upload",
|
||||
"panel.chore_image_remove": "Remove",
|
||||
"panel.chore_image_empty": "No picture",
|
||||
"panel.chore_image_uploading": "Uploading…",
|
||||
"panel.chore_image_failed": "Upload failed",
|
||||
"panel.chore_image_too_large": "That image is too large (max 2 MB).",
|
||||
"panel.chore_image_bad_type": "Choose a JPEG, PNG or WebP image.",
|
||||
"child.editor.pre_reader": "Picture-only mode (for young children)",
|
||||
"child.editor.pre_reader_labels": "Show names in picture mode",
|
||||
"common.design.accessible": "Accessible"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "points manuellement",
|
||||
"activity.reason_allocated_to_pool": "Alloué à la cagnotte : {name}",
|
||||
"activity.reason_admin_adjustment": "Ajustement manuel",
|
||||
"activity.reason_bonus": "Bonus : {name}",
|
||||
"activity.reason_penalty": "Pénalité : {name}",
|
||||
"activity.reason_perfect_week": "Bonus de semaine parfaite !",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Choisissez une icône MDI",
|
||||
"panel.settings_points_icon_label": "Icône des points",
|
||||
"panel.settings_points_name_hint": "Comment vous les appelez, ex. Étoiles ou Pièces",
|
||||
"panel.settings_quick_points_label": "Montants rapides de points",
|
||||
"panel.settings_quick_points_hint": "Montants séparés par des virgules pour les boutons + et − de chaque carte enfant. Trois au maximum sont utilisés.",
|
||||
"panel.settings_points_name_label": "Nom des points",
|
||||
"panel.settings_retention_hint": "Combien de jours d'historique de complétion garder",
|
||||
"panel.settings_retention_label": "Rétention",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"panel.chore_require_photo_label": "Exiger une preuve photo",
|
||||
"panel.chore_require_photo_hint": "Les réalisations doivent être approuvées par un parent ; une photo de preuve (si fournie) s'affiche dans les approbations.",
|
||||
"panel.activity_view_photo": "Voir la photo",
|
||||
"panel.adjust_add": "Ajouter",
|
||||
"panel.adjust_add_title": "Ajouter {amount} {points} à {child}",
|
||||
"panel.adjust_amount": "Montant",
|
||||
"panel.adjust_custom_title": "Ajuster les points de {child} d'un montant personnalisé",
|
||||
"panel.adjust_dialog_title": "Ajuster les points — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} pour {child}",
|
||||
"panel.adjust_err_amount": "Saisissez un montant entre 1 et 10000.",
|
||||
"panel.adjust_reason": "Motif (facultatif)",
|
||||
"panel.adjust_remove": "Retirer",
|
||||
"panel.adjust_remove_title": "Retirer {amount} {points} à {child}",
|
||||
"panel.swap_btn": "Demander un échange",
|
||||
"panel.swap_dialog_title": "Échange : {chore}",
|
||||
"panel.swap_requester": "Donner le tour d'aujourd'hui à",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Manquements",
|
||||
"panel.health_count_storage": "Stockage",
|
||||
"panel.chore_icon_label": "Image",
|
||||
"panel.chore_image_label": "Image",
|
||||
"panel.chore_image_hint": "Une photo affichée à la place de l'icône. Redimensionnée à 512px à l'envoi.",
|
||||
"panel.chore_image_upload": "Envoyer",
|
||||
"panel.chore_image_remove": "Retirer",
|
||||
"panel.chore_image_empty": "Aucune image",
|
||||
"panel.chore_image_uploading": "Envoi…",
|
||||
"panel.chore_image_failed": "Échec de l'envoi",
|
||||
"panel.chore_image_too_large": "Cette image est trop volumineuse (max 2 Mo).",
|
||||
"panel.chore_image_bad_type": "Choisissez une image JPEG, PNG ou WebP.",
|
||||
"child.editor.pre_reader": "Mode images seules (jeunes enfants)",
|
||||
"child.editor.pre_reader_labels": "Afficher les noms en mode images",
|
||||
"common.design.accessible": "Accessible"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "poeng manuelt",
|
||||
"activity.reason_allocated_to_pool": "Tildelt til sparegris: {name}",
|
||||
"activity.reason_admin_adjustment": "Manuell justering",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Straff: {name}",
|
||||
"activity.reason_perfect_week": "Perfekt uke-bonus!",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Velg et MDI-ikon",
|
||||
"panel.settings_points_icon_label": "Poengikon",
|
||||
"panel.settings_points_name_hint": "Hva du kaller dem, f.eks. Stjerner eller Mynter",
|
||||
"panel.settings_quick_points_label": "Hurtigbeløp for poeng",
|
||||
"panel.settings_quick_points_hint": "Kommaseparerte beløp for +- og −-knappene på hvert barnekort. Opptil tre brukes.",
|
||||
"panel.settings_points_name_label": "Poengnavn",
|
||||
"panel.settings_retention_hint": "Hvor mange dager med fullføringshistorikk som beholdes",
|
||||
"panel.settings_retention_label": "Oppbevaring",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"panel.chore_require_photo_label": "Krev bildebevis",
|
||||
"panel.chore_require_photo_hint": "Fullføringer må godkjennes av forelder; et bevisbilde (hvis lagt ved) vises i godkjenninger.",
|
||||
"panel.activity_view_photo": "Vis bilde",
|
||||
"panel.adjust_add": "Legg til",
|
||||
"panel.adjust_add_title": "Legg til {amount} {points} til {child}",
|
||||
"panel.adjust_amount": "Beløp",
|
||||
"panel.adjust_custom_title": "Juster poengene til {child} med et eget beløp",
|
||||
"panel.adjust_dialog_title": "Juster poeng — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} til {child}",
|
||||
"panel.adjust_err_amount": "Skriv inn et beløp mellom 1 og 10000.",
|
||||
"panel.adjust_reason": "Årsak (valgfritt)",
|
||||
"panel.adjust_remove": "Trekk fra",
|
||||
"panel.adjust_remove_title": "Trekk fra {amount} {points} fra {child}",
|
||||
"panel.swap_btn": "Be om bytte",
|
||||
"panel.swap_dialog_title": "Bytte: {chore}",
|
||||
"panel.swap_requester": "Gi dagens tur til",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Glemt",
|
||||
"panel.health_count_storage": "Lagring",
|
||||
"panel.chore_icon_label": "Bilde",
|
||||
"panel.chore_image_label": "Bilde",
|
||||
"panel.chore_image_hint": "Et bilde som vises i stedet for ikonet. Skaleres til 512px ved opplasting.",
|
||||
"panel.chore_image_upload": "Last opp",
|
||||
"panel.chore_image_remove": "Fjern",
|
||||
"panel.chore_image_empty": "Ingen bilde",
|
||||
"panel.chore_image_uploading": "Laster opp…",
|
||||
"panel.chore_image_failed": "Opplasting mislyktes",
|
||||
"panel.chore_image_too_large": "Bildet er for stort (maks 2 MB).",
|
||||
"panel.chore_image_bad_type": "Velg et JPEG-, PNG- eller WebP-bilde.",
|
||||
"child.editor.pre_reader": "Kun bilder (for små barn)",
|
||||
"child.editor.pre_reader_labels": "Vis navn i bildemodus",
|
||||
"common.design.accessible": "Tilgjengelig"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "poeng manuelt",
|
||||
"activity.reason_allocated_to_pool": "Tildelt til sparegris: {name}",
|
||||
"activity.reason_admin_adjustment": "Manuell justering",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Straff: {name}",
|
||||
"activity.reason_perfect_week": "Perfekt veke-bonus!",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Vel eit MDI-ikon",
|
||||
"panel.settings_points_icon_label": "Poengikon",
|
||||
"panel.settings_points_name_hint": "Kva du kallar dei, t.d. Stjerner eller Myntar",
|
||||
"panel.settings_quick_points_label": "Snøggbeløp for poeng",
|
||||
"panel.settings_quick_points_hint": "Kommaseparerte beløp for +- og −-knappane på kvart barnekort. Opptil tre blir brukte.",
|
||||
"panel.settings_points_name_label": "Poengnamn",
|
||||
"panel.settings_retention_hint": "Kor mange dagar med fullføringshistorikk å ta vare på",
|
||||
"panel.settings_retention_label": "Oppbevaring",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"panel.chore_require_photo_label": "Krev biletbevis",
|
||||
"panel.chore_require_photo_hint": "Fullføringar må godkjennast av forelder; eit bevisbilete (om lagt ved) vert vist i godkjenningar.",
|
||||
"panel.activity_view_photo": "Vis bilete",
|
||||
"panel.adjust_add": "Legg til",
|
||||
"panel.adjust_add_title": "Legg til {amount} {points} til {child}",
|
||||
"panel.adjust_amount": "Beløp",
|
||||
"panel.adjust_custom_title": "Juster poenga til {child} med eit eige beløp",
|
||||
"panel.adjust_dialog_title": "Juster poeng — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} til {child}",
|
||||
"panel.adjust_err_amount": "Skriv inn eit beløp mellom 1 og 10000.",
|
||||
"panel.adjust_reason": "Årsak (valfritt)",
|
||||
"panel.adjust_remove": "Trekk frå",
|
||||
"panel.adjust_remove_title": "Trekk frå {amount} {points} frå {child}",
|
||||
"panel.swap_btn": "Be om byte",
|
||||
"panel.swap_dialog_title": "Byte: {chore}",
|
||||
"panel.swap_requester": "Gi dagens tur til",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Gløymt",
|
||||
"panel.health_count_storage": "Lagring",
|
||||
"panel.chore_icon_label": "Bilete",
|
||||
"panel.chore_image_label": "Bilete",
|
||||
"panel.chore_image_hint": "Eit bilete som blir vist i staden for ikonet. Skalert til 512px ved opplasting.",
|
||||
"panel.chore_image_upload": "Last opp",
|
||||
"panel.chore_image_remove": "Fjern",
|
||||
"panel.chore_image_empty": "Ikkje noko bilete",
|
||||
"panel.chore_image_uploading": "Lastar opp…",
|
||||
"panel.chore_image_failed": "Opplastinga mislukkast",
|
||||
"panel.chore_image_too_large": "Biletet er for stort (maks 2 MB).",
|
||||
"panel.chore_image_bad_type": "Vel eit JPEG-, PNG- eller WebP-bilete.",
|
||||
"child.editor.pre_reader": "Berre bilete (for små born)",
|
||||
"child.editor.pre_reader_labels": "Vis namn i biletemodus",
|
||||
"common.design.accessible": "Tilgjengeleg"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "pontos manualmente",
|
||||
"activity.reason_allocated_to_pool": "Alocado ao cofrinho: {name}",
|
||||
"activity.reason_admin_adjustment": "Ajuste manual",
|
||||
"activity.reason_bonus": "Bônus: {name}",
|
||||
"activity.reason_penalty": "Penalidade: {name}",
|
||||
"activity.reason_perfect_week": "Bônus de semana perfeita!",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Escolha qualquer ícone MDI",
|
||||
"panel.settings_points_icon_label": "Ícone dos pontos",
|
||||
"panel.settings_points_name_hint": "Como você os chama, ex: Estrelas ou Moedas",
|
||||
"panel.settings_quick_points_label": "Valores rápidos de pontos",
|
||||
"panel.settings_quick_points_hint": "Valores separados por vírgulas para os botões + e − em cada cartão de criança. São usados até três.",
|
||||
"panel.settings_points_name_label": "Nome dos pontos",
|
||||
"panel.settings_retention_hint": "Quantos dias de histórico de conclusões manter",
|
||||
"panel.settings_retention_label": "Retenção",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"panel.chore_require_photo_label": "Exigir prova fotográfica",
|
||||
"panel.chore_require_photo_hint": "As conclusões precisam ser aprovadas pelos pais; uma foto de prova (se fornecida) aparece nas aprovações.",
|
||||
"panel.activity_view_photo": "Ver foto",
|
||||
"panel.adjust_add": "Adicionar",
|
||||
"panel.adjust_add_title": "Adicionar {amount} {points} para {child}",
|
||||
"panel.adjust_amount": "Valor",
|
||||
"panel.adjust_custom_title": "Ajustar os pontos de {child} com um valor personalizado",
|
||||
"panel.adjust_dialog_title": "Ajustar pontos — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} para {child}",
|
||||
"panel.adjust_err_amount": "Digite um valor entre 1 e 10000.",
|
||||
"panel.adjust_reason": "Motivo (opcional)",
|
||||
"panel.adjust_remove": "Remover",
|
||||
"panel.adjust_remove_title": "Remover {amount} {points} de {child}",
|
||||
"panel.swap_btn": "Pedir troca",
|
||||
"panel.swap_dialog_title": "Troca: {chore}",
|
||||
"panel.swap_requester": "Dar a vez de hoje a",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Falhas",
|
||||
"panel.health_count_storage": "Armazenamento",
|
||||
"panel.chore_icon_label": "Imagem",
|
||||
"panel.chore_image_label": "Imagem",
|
||||
"panel.chore_image_hint": "Uma foto exibida no lugar do ícone. Redimensionada para 512px no envio.",
|
||||
"panel.chore_image_upload": "Enviar",
|
||||
"panel.chore_image_remove": "Remover",
|
||||
"panel.chore_image_empty": "Sem imagem",
|
||||
"panel.chore_image_uploading": "Enviando…",
|
||||
"panel.chore_image_failed": "Falha no envio",
|
||||
"panel.chore_image_too_large": "Essa imagem é muito grande (máx. 2 MB).",
|
||||
"panel.chore_image_bad_type": "Escolha uma imagem JPEG, PNG ou WebP.",
|
||||
"child.editor.pre_reader": "Modo só imagens (crianças pequenas)",
|
||||
"child.editor.pre_reader_labels": "Mostrar nomes no modo imagens",
|
||||
"common.design.accessible": "Acessível"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "pontos manualmente",
|
||||
"activity.reason_allocated_to_pool": "Alocado ao mealheiro: {name}",
|
||||
"activity.reason_admin_adjustment": "Ajuste manual",
|
||||
"activity.reason_bonus": "Bónus: {name}",
|
||||
"activity.reason_penalty": "Penalidade: {name}",
|
||||
"activity.reason_perfect_week": "Bónus de semana perfeita!",
|
||||
@@ -806,6 +807,8 @@
|
||||
"panel.settings_points_icon_hint": "Escolha qualquer ícone MDI",
|
||||
"panel.settings_points_icon_label": "Ícone dos pontos",
|
||||
"panel.settings_points_name_hint": "Como os chama, ex. Estrelas ou Moedas",
|
||||
"panel.settings_quick_points_label": "Valores rápidos de pontos",
|
||||
"panel.settings_quick_points_hint": "Valores separados por vírgulas para os botões + e − em cada cartão de criança. São usados até três.",
|
||||
"panel.settings_points_name_label": "Nome dos pontos",
|
||||
"panel.settings_retention_hint": "Quantos dias de histórico de conclusões manter",
|
||||
"panel.settings_retention_label": "Retenção",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"panel.chore_require_photo_label": "Exigir prova fotográfica",
|
||||
"panel.chore_require_photo_hint": "As conclusões têm de ser aprovadas pelos pais; uma foto de prova (se fornecida) aparece nas aprovações.",
|
||||
"panel.activity_view_photo": "Ver foto",
|
||||
"panel.adjust_add": "Adicionar",
|
||||
"panel.adjust_add_title": "Adicionar {amount} {points} a {child}",
|
||||
"panel.adjust_amount": "Valor",
|
||||
"panel.adjust_custom_title": "Ajustar os pontos de {child} com um valor personalizado",
|
||||
"panel.adjust_dialog_title": "Ajustar pontos — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} para {child}",
|
||||
"panel.adjust_err_amount": "Introduza um valor entre 1 e 10000.",
|
||||
"panel.adjust_reason": "Motivo (opcional)",
|
||||
"panel.adjust_remove": "Retirar",
|
||||
"panel.adjust_remove_title": "Retirar {amount} {points} a {child}",
|
||||
"panel.swap_btn": "Pedir troca",
|
||||
"panel.swap_dialog_title": "Troca: {chore}",
|
||||
"panel.swap_requester": "Dar a vez de hoje a",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Falhas",
|
||||
"panel.health_count_storage": "Armazenamento",
|
||||
"panel.chore_icon_label": "Imagem",
|
||||
"panel.chore_image_label": "Imagem",
|
||||
"panel.chore_image_hint": "Uma foto mostrada em vez do ícone. Redimensionada para 512px no envio.",
|
||||
"panel.chore_image_upload": "Carregar",
|
||||
"panel.chore_image_remove": "Remover",
|
||||
"panel.chore_image_empty": "Sem imagem",
|
||||
"panel.chore_image_uploading": "A carregar…",
|
||||
"panel.chore_image_failed": "Falha no carregamento",
|
||||
"panel.chore_image_too_large": "Essa imagem é demasiado grande (máx. 2 MB).",
|
||||
"panel.chore_image_bad_type": "Escolha uma imagem JPEG, PNG ou WebP.",
|
||||
"child.editor.pre_reader": "Modo só imagens (crianças pequenas)",
|
||||
"child.editor.pre_reader_labels": "Mostrar nomes no modo imagens",
|
||||
"common.design.accessible": "Acessível"
|
||||
|
||||
@@ -85,6 +85,11 @@ class TaskMateActivityCard extends LitElement {
|
||||
if (reason.startsWith('Perfect week bonus!')) {
|
||||
return this._t('activity.reason_perfect_week');
|
||||
}
|
||||
// Manual adjustment from the admin panel (#746). Deliberately NOT in
|
||||
// _UNDO_DENY_PREFIXES — a manual adjustment must stay reversible.
|
||||
if (reason === 'Admin panel adjustment') {
|
||||
return this._t('activity.reason_admin_adjustment');
|
||||
}
|
||||
const weekendMatch = reason.match(/^Weekend bonus \(×(\d+)\)$/);
|
||||
if (weekendMatch) {
|
||||
return this._t('activity.reason_weekend_bonus', { multiplier: weekendMatch[1] });
|
||||
|
||||
@@ -914,6 +914,24 @@ class TaskMateChildCard extends LitElement {
|
||||
|
||||
/* A chore picture sits where the digit would, so it has to carry the
|
||||
same white-on-colour weight the numeral gets from its text-shadow. */
|
||||
/* Uploaded chore pictures (#750). min-width/min-height:0 is load-bearing:
|
||||
grid and flex items default to min-*:auto, whose content-based minimum
|
||||
is the image's intrinsic aspect ratio, so without it a PORTRAIT photo
|
||||
overflows a square slot (measured 64x90 in a 64x64 pre-reader tile)
|
||||
because that minimum beats height:100%. Landscape photos hide it. */
|
||||
.chore-badge-img {
|
||||
width: 100%; height: 100%; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: inherit; display: block;
|
||||
}
|
||||
.tmd-glyph-img {
|
||||
width: 1.4em; height: 1.4em; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: 6px; display: block;
|
||||
}
|
||||
.pre-tile-icon img {
|
||||
width: 100%; height: 100%; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: 12px; display: block;
|
||||
}
|
||||
|
||||
.chore-number-badge ha-icon {
|
||||
--mdc-icon-size: 22px;
|
||||
color: #fff;
|
||||
@@ -2365,9 +2383,14 @@ class TaskMateChildCard extends LitElement {
|
||||
* The icon inherits the row's accent so it reads as part of the style.
|
||||
*/
|
||||
_choreGlyph(chore) {
|
||||
return chore.icon
|
||||
? html`<ha-icon icon="${chore.icon}" class="tmd-glyph-icon"></ha-icon>`
|
||||
: this._choreEmoji(chore);
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
if (v.kind === "image") {
|
||||
return html`<img class="tmd-glyph-img" src="${v.url}" alt="" loading="lazy">`;
|
||||
}
|
||||
if (v.kind === "icon") {
|
||||
return html`<ha-icon icon="${v.icon}" class="tmd-glyph-icon"></ha-icon>`;
|
||||
}
|
||||
return this._choreEmoji(chore);
|
||||
}
|
||||
|
||||
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
|
||||
@@ -3302,7 +3325,8 @@ class TaskMateChildCard extends LitElement {
|
||||
const points = chore.effective_points ?? chore.points ?? 0;
|
||||
const stars = Math.max(1, Math.min(5, Math.round(points / 2) || 1));
|
||||
|
||||
const icon = chore.icon
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
const icon = (v.kind === "icon" ? v.icon : "")
|
||||
|| this._getTimeCategoryIcon(chore.time_category)
|
||||
|| 'mdi:checkbox-marked-circle-outline';
|
||||
|
||||
@@ -3316,7 +3340,11 @@ class TaskMateChildCard extends LitElement {
|
||||
? this._handleUndo(chore, child, childCompletionsToday)
|
||||
: this._handleComplete(chore, child))}
|
||||
>
|
||||
<div class="pre-tile-icon"><ha-icon icon="${icon}"></ha-icon></div>
|
||||
<div class="pre-tile-icon">
|
||||
${v.kind === "image"
|
||||
? html`<img src="${v.url}" alt="" loading="lazy">`
|
||||
: html`<ha-icon icon="${icon}"></ha-icon>`}
|
||||
</div>
|
||||
${isDone ? html`<div class="pre-tile-tick"><ha-icon icon="mdi:check-bold"></ha-icon></div>` : ''}
|
||||
<div class="pre-tile-stars">
|
||||
${Array.from({ length: stars }, () => html`<ha-icon icon="mdi:star"></ha-icon>`)}
|
||||
@@ -3336,11 +3364,14 @@ class TaskMateChildCard extends LitElement {
|
||||
* a picture can't work on one and be invisible on the other.
|
||||
*/
|
||||
_choreNumberBadge(chore, colorClass, choreNumber) {
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
return html`
|
||||
<div class="chore-number-badge ${colorClass}">
|
||||
${chore.icon
|
||||
? html`<ha-icon icon="${chore.icon}"></ha-icon>`
|
||||
: choreNumber}
|
||||
${v.kind === "image"
|
||||
? html`<img class="chore-badge-img" src="${v.url}" alt="" loading="lazy">`
|
||||
: v.kind === "icon"
|
||||
? html`<ha-icon icon="${v.icon}"></ha-icon>`
|
||||
: choreNumber}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,24 @@
|
||||
|
||||
window.__taskmate_design = { IDS, resolve, isDark, apply, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
|
||||
|
||||
/**
|
||||
* The single place that decides what a chore looks like (#750).
|
||||
*
|
||||
* Precedence: uploaded picture → MDI icon → the call site's own fallback.
|
||||
*
|
||||
* Returns a plain descriptor rather than a template, for two reasons: this
|
||||
* file has no ES-module imports and no Lit dependency and must keep it that
|
||||
* way, and the `icon` case is not uniform across sites — the reorder card
|
||||
* renders chore.icon as an <ha-icon> only when it starts with "mdi:" and as
|
||||
* raw text otherwise, because the field can hold an emoji. A descriptor lets
|
||||
* each site keep its existing icon and fallback handling untouched.
|
||||
*/
|
||||
window.__taskmate_chore_visual = window.__taskmate_chore_visual || function (chore) {
|
||||
if (chore && chore.image_url) return { kind: "image", url: chore.image_url };
|
||||
if (chore && chore.icon) return { kind: "icon", icon: chore.icon };
|
||||
return { kind: "none" };
|
||||
};
|
||||
|
||||
// ── Card-picker entity suggestions (HA 2026.6+) ────────────────────────────
|
||||
// Custom cards may add getEntitySuggestion(hass, entityId) to their
|
||||
// window.customCards entry; when a user picks an entity in the card picker,
|
||||
|
||||
@@ -330,6 +330,27 @@ class TaskMatePanel extends HTMLElement {
|
||||
return fn ? fn(this._hass, key, params) : key;
|
||||
}
|
||||
|
||||
// Reason prefixes for *derived* (automatic) transactions, which can't be
|
||||
// undone in isolation — mirrors the backend deny-list in coord_points.py and
|
||||
// the copy in taskmate-activity-card.js. Everything else (penalty, bonus,
|
||||
// gift, manual add/remove) is reversible.
|
||||
//
|
||||
// This is a deny-list, NOT an allow-list (#761): manual adjustments carry
|
||||
// arbitrary or empty reasons, so they cannot be recognised positively.
|
||||
// tests/test_panel_undo_deny_list.py pins all three copies together.
|
||||
static get _UNDO_DENY_PREFIXES() {
|
||||
return [
|
||||
"Weekend bonus", "Streak milestone bonus", "Perfect week bonus",
|
||||
"Allocated to pool:", "Pool refund", "Points decay",
|
||||
"Savings interest", "Badge",
|
||||
];
|
||||
}
|
||||
|
||||
_txnReversible(reason) {
|
||||
const r = reason || "";
|
||||
return !TaskMatePanel._UNDO_DENY_PREFIXES.some(p => r.startsWith(p));
|
||||
}
|
||||
|
||||
// Transaction reasons are stored in English in the DB (e.g. "Penalty: Messy room").
|
||||
// Mirror the activity-card mapping so panel views render in the user's language.
|
||||
_translateReason(reason) {
|
||||
@@ -352,6 +373,11 @@ class TaskMatePanel extends HTMLElement {
|
||||
if (reason.startsWith('Perfect week bonus!')) {
|
||||
return this._t('activity.reason_perfect_week');
|
||||
}
|
||||
// Manual adjustment from the admin panel (#746). An exact match rather than a
|
||||
// prefix: unlike "Bonus: <name>" there is no trailing detail to extract.
|
||||
if (reason === 'Admin panel adjustment') {
|
||||
return this._t('activity.reason_admin_adjustment');
|
||||
}
|
||||
const weekendMatch = reason.match(/^Weekend bonus \(×(\d+)\)$/);
|
||||
if (weekendMatch) {
|
||||
return this._t('activity.reason_weekend_bonus', { multiplier: weekendMatch[1] });
|
||||
@@ -496,6 +522,18 @@ class TaskMatePanel extends HTMLElement {
|
||||
if (act === "add-child") { this._openChildDialog(null); return; }
|
||||
if (act === "gift-points") { this._openGiftDialog(); return; }
|
||||
if (act === "save-gift") { this._doGiftPoints(); return; }
|
||||
if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; }
|
||||
if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; }
|
||||
if (act === "chore-image-pick") { this._pickChoreImage(); return; }
|
||||
if (act === "chore-image-remove") {
|
||||
// Clears the dialog value only — the file is deleted on save by the
|
||||
// coordinator, so cancelling the dialog cannot destroy the picture.
|
||||
this._dialog.data.image_url = "";
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
if (act === "save-adjust-add") { this._saveAdjustDialog(1); return; }
|
||||
if (act === "save-adjust-remove") { this._saveAdjustDialog(-1); return; }
|
||||
if (act === "edit-child") { this._openChildDialog(t.dataset.id); return; }
|
||||
if (act === "delete-child") { this._confirmDelete("child", t.dataset.id); return; }
|
||||
if (act === "save-child") { this._doSaveChild(); return; }
|
||||
@@ -1402,6 +1440,85 @@ class TaskMatePanel extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
_pickChoreImage() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/jpeg,image/png,image/webp";
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (file) this._uploadChoreImage(file);
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
// Downscale to 512px before upload: these render in slots between 20 and
|
||||
// 128px, so anything larger is bytes every device re-downloads for nothing.
|
||||
_downscaleChoreImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const max = 512;
|
||||
let { width, height } = img;
|
||||
if (width > max || height > max) {
|
||||
const scale = Math.min(max / width, max / height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob(b => (b ? resolve(b) : reject(new Error("encode failed"))), "image/jpeg", 0.8);
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("decode failed")); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async _uploadChoreImage(file) {
|
||||
if (this._choreImageBusy) return;
|
||||
this._choreImageBusy = true;
|
||||
this._render();
|
||||
try {
|
||||
const blob = await this._downscaleChoreImage(file);
|
||||
const post = () => {
|
||||
const token = this._hass && this._hass.auth && this._hass.auth.data
|
||||
&& this._hass.auth.data.access_token;
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, "chore.jpg");
|
||||
return fetch("/api/taskmate/image", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
};
|
||||
// The cached access token can go stale mid-session, so refresh and retry
|
||||
// once on a 401 before giving up. Mirrors the child card's photo upload.
|
||||
if (this._hass && this._hass.auth && this._hass.auth.expired
|
||||
&& this._hass.auth.refreshAccessToken) {
|
||||
try { await this._hass.auth.refreshAccessToken(); } catch (e) { /* surfaced below */ }
|
||||
}
|
||||
let resp = await post();
|
||||
if (resp.status === 401 && this._hass && this._hass.auth
|
||||
&& this._hass.auth.refreshAccessToken) {
|
||||
await this._hass.auth.refreshAccessToken();
|
||||
resp = await post();
|
||||
}
|
||||
if (resp.status === 413) { this._showToast("err", this._t("panel.chore_image_too_large")); return; }
|
||||
if (resp.status === 400) { this._showToast("err", this._t("panel.chore_image_bad_type")); return; }
|
||||
if (!resp.ok) { this._showToast("err", this._t("panel.chore_image_failed")); return; }
|
||||
const body = await resp.json();
|
||||
if (this._dialog && body.image_url) this._dialog.data.image_url = body.image_url;
|
||||
} catch (err) {
|
||||
this._showToast("err", this._t("panel.chore_image_failed"));
|
||||
} finally {
|
||||
this._choreImageBusy = false;
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
async _doSaveChore() {
|
||||
this._syncIconPickers();
|
||||
const d = this._dialog.data;
|
||||
@@ -1411,6 +1528,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
name: d.name.trim(),
|
||||
description: d.description || "",
|
||||
icon: d.icon || "",
|
||||
image_url: d.image_url || "",
|
||||
points: Number(d.points) || 0,
|
||||
assigned_to: d.assigned_to || [],
|
||||
requires_approval: !!d.requires_approval,
|
||||
@@ -2914,6 +3032,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(child.total_points_earned || 0)}</div><div class="tm-stat-label">${this._t("panel.child_stat_earned")}</div></div>
|
||||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(child.total_chores_completed || 0)}</div><div class="tm-stat-label">${this._t("panel.child_stat_done")}</div></div>
|
||||
</div>
|
||||
${this._renderAdjustStrip(child, pointsName)}
|
||||
<div class="tm-card-foot">
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="edit-child" data-id="${this._esc(child.id)}">${this._t("panel.btn_edit")}</button>
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="reorder-chores-for-child" data-id="${this._esc(child.id)}" title="${this._t("panel.chore_order_title")}">⇅ ${this._t("panel.chore_order_title")}</button>
|
||||
@@ -2923,6 +3042,36 @@ class TaskMatePanel extends HTMLElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Manual point adjustment (#746). Minus amounts descend and plus amounts
|
||||
// ascend so the two smallest meet in the middle and the row reads outward
|
||||
// from zero. The two `-set` spans are load-bearing: with all seven buttons as
|
||||
// direct flex children, a narrow card wraps only the last one and orphans ⋯
|
||||
// on a row of its own. Grouping makes each side wrap as a whole unit.
|
||||
_renderAdjustStrip(child, pointsName) {
|
||||
const amounts = this._quickPointAmounts();
|
||||
const id = this._esc(child.id);
|
||||
const name = child.name || this._t("panel.child_unnamed");
|
||||
const btn = (delta) => {
|
||||
const amount = Math.abs(delta);
|
||||
const label = this._t(delta < 0 ? "panel.adjust_remove_title" : "panel.adjust_add_title",
|
||||
{ amount, points: pointsName, child: name });
|
||||
return `<button type="button" class="tm-btn tm-btn-sm ${delta < 0 ? "tm-btn-danger" : "tm-btn-raised"}"
|
||||
data-act="adjust-points" data-id="${id}" data-delta="${delta}"
|
||||
title="${this._esc(label)}" aria-label="${this._esc(label)}">${delta < 0 ? "−" : "+"}${amount}</button>`;
|
||||
};
|
||||
const customLabel = this._t("panel.adjust_custom_title", { child: name });
|
||||
return `
|
||||
<div class="tm-points-adjust">
|
||||
<span class="tm-points-adjust-set">${amounts.slice().reverse().map(a => btn(-a)).join("")}</span>
|
||||
<span class="tm-points-adjust-gap"></span>
|
||||
<span class="tm-points-adjust-set">
|
||||
${amounts.map(a => btn(a)).join("")}
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="adjust-points-custom" data-id="${id}"
|
||||
title="${this._esc(customLabel)}" aria-label="${this._esc(customLabel)}">⋯</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// -- Activity tab ------------------------------------------------------
|
||||
_renderActivityTab() {
|
||||
const pendingCompletions = this._state.pending_completions || [];
|
||||
@@ -3080,7 +3229,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<tbody>
|
||||
${[...transactions].reverse().map(t => {
|
||||
const child = childById[t.child_id];
|
||||
const undoable = typeof t.reason === "string" && (t.reason.startsWith("Penalty: ") || t.reason.startsWith("Bonus: "));
|
||||
const undoable = this._txnReversible(t.reason);
|
||||
return `
|
||||
<tr class="tm-row">
|
||||
<td class="tm-meta">${this._esc(this._timeAgo(t.created_at))}</td>
|
||||
@@ -4197,6 +4346,10 @@ class TaskMatePanel extends HTMLElement {
|
||||
${["classic","playroom","console","cleanpro"].map(v => `<option value="${v}" ${v === (s.card_design || "classic") ? "selected" : ""}>${this._esc(this._t("common.design." + v))}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_quick_points_label")}<small>${this._t("panel.settings_quick_points_hint")}</small></div>
|
||||
<input type="text" class="tm-input" data-setting="quick_point_amounts" value="${this._esc(s.quick_point_amounts == null ? "" : s.quick_point_amounts)}" placeholder="5, 10, 20">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4773,6 +4926,73 @@ class TaskMatePanel extends HTMLElement {
|
||||
this._showToast("ok", this._t("panel.gift_sent"));
|
||||
}
|
||||
|
||||
_openAdjustDialog(childId) {
|
||||
const child = (this._state.children || []).find(c => c.id === childId);
|
||||
if (!child) return;
|
||||
this._openDialog({ kind: "adjust", data: { child_id: childId, points: 10, reason: "" } });
|
||||
}
|
||||
|
||||
_renderAdjustDialog() {
|
||||
const d = this._dialog.data;
|
||||
const child = (this._state.children || []).find(c => c.id === d.child_id) || {};
|
||||
return this._dialogShell(
|
||||
this._t("panel.adjust_dialog_title", { name: child.name || this._t("panel.child_unnamed") }),
|
||||
[
|
||||
this._field(this._t("panel.adjust_amount"), "points", d.points, "number"),
|
||||
this._field(this._t("panel.adjust_reason"), "reason", d.reason, "text"),
|
||||
].join(""),
|
||||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||||
<button type="button" class="tm-btn tm-btn-danger" data-act="save-adjust-remove">${this._t("panel.adjust_remove")}</button>
|
||||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-adjust-add">${this._t("panel.adjust_add")}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
// `sign` is +1 or -1, taken from which footer button was pressed, so the
|
||||
// dialog needs no direction control of its own.
|
||||
async _saveAdjustDialog(sign) {
|
||||
const d = this._dialog.data;
|
||||
const amount = Number(d.points);
|
||||
if (!Number.isInteger(amount) || amount < 1 || amount > 10000) {
|
||||
this._showToast("err", this._t("panel.adjust_err_amount"));
|
||||
return;
|
||||
}
|
||||
const reason = String(d.reason || "").trim() || "Admin panel adjustment";
|
||||
const ok = await this._doAdjustPoints(d.child_id, sign * amount, reason);
|
||||
if (ok) this._closeDialog(true);
|
||||
}
|
||||
|
||||
// Manual point adjustment (#746). The busy key is the CHILD, not the button,
|
||||
// so a laggy tablet can neither double-award one amount nor race +5 against
|
||||
// −10 on the same balance.
|
||||
async _doAdjustPoints(childId, delta, reason = "Admin panel adjustment") {
|
||||
const amount = Math.abs(Number(delta) || 0);
|
||||
if (!childId || !Number.isInteger(amount) || amount < 1 || amount > 10000) {
|
||||
this._showToast("err", this._t("panel.adjust_err_amount"));
|
||||
return false;
|
||||
}
|
||||
if (!this._adjustBusy) this._adjustBusy = new Set();
|
||||
if (this._adjustBusy.has(childId)) return false;
|
||||
this._adjustBusy.add(childId);
|
||||
try {
|
||||
const service = delta > 0 ? "add_points" : "remove_points";
|
||||
const data = { child_id: childId, points: amount };
|
||||
if (reason) data.reason = reason;
|
||||
const { ok, err } = await this._callService(service, data);
|
||||
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return false; }
|
||||
await this._fetchState();
|
||||
const child = (this._state.children || []).find(c => c.id === childId);
|
||||
this._showToast("ok", this._t("panel.adjust_done", {
|
||||
sign: delta > 0 ? "+" : "−",
|
||||
amount,
|
||||
points: this._state.settings.points_name || this._t("common.points"),
|
||||
child: (child && child.name) || "",
|
||||
}));
|
||||
return true;
|
||||
} finally {
|
||||
this._adjustBusy.delete(childId);
|
||||
}
|
||||
}
|
||||
|
||||
_openSwapDialog(choreId) {
|
||||
const c = (this._state.chores || []).find(x => x.id === choreId);
|
||||
if (!c) return;
|
||||
@@ -4813,6 +5033,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
_renderDialog() {
|
||||
if (this._dialog.kind === "swap") return this._renderSwapDialog();
|
||||
if (this._dialog.kind === "gift") return this._renderGiftDialog();
|
||||
if (this._dialog.kind === "adjust") return this._renderAdjustDialog();
|
||||
if (this._dialog.kind === "child") return this._renderChildDialog();
|
||||
if (this._dialog.kind === "chore") return this._renderChoreDialog();
|
||||
if (this._dialog.kind === "reward") return this._renderRewardDialog();
|
||||
@@ -4935,6 +5156,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
`<div class="tm-field-row">
|
||||
${this._field(this._t("panel.chore_description_label"), "description", d.description, "text")}
|
||||
${this._iconPickerField(this._t("panel.chore_icon_label"), "icon", d.icon)}
|
||||
${this._choreImageField(d.image_url || "")}
|
||||
</div>`,
|
||||
this._select(this._t("panel.chore_task_type_label"), "task_type", d.task_type || "standard", [
|
||||
{ v: "standard", l: this._t("panel.chore_task_type_standard") },
|
||||
@@ -5706,6 +5928,27 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** The chore picture well (#750): thumbnail or empty state, Upload, Remove. */
|
||||
_choreImageField(value) {
|
||||
const busy = this._choreImageBusy;
|
||||
return `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._t("panel.chore_image_label")}</span>
|
||||
<div class="tm-image-well">
|
||||
${value
|
||||
? `<img class="tm-image-thumb" src="${this._esc(value)}" alt="">`
|
||||
: `<div class="tm-image-empty">${this._t("panel.chore_image_empty")}</div>`}
|
||||
<div class="tm-image-actions">
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="chore-image-pick" ${busy ? "disabled" : ""}>
|
||||
${busy ? this._t("panel.chore_image_uploading") : this._t("panel.chore_image_upload")}
|
||||
</button>
|
||||
${value ? `<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="chore-image-remove">${this._t("panel.chore_image_remove")}</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span class="tm-field-hint">${this._t("panel.chore_image_hint")}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_iconPickerField(label, name, value) {
|
||||
return `
|
||||
<div class="tm-field">
|
||||
@@ -5929,6 +6172,20 @@ class TaskMatePanel extends HTMLElement {
|
||||
return new Intl.NumberFormat().format(n);
|
||||
}
|
||||
|
||||
// Quick point-adjust amounts (#746). Stored as a comma-separated string like
|
||||
// "5, 10, 20" — same shape as streak_milestones — so the generic settings
|
||||
// collector can round-trip it as a plain text input. Junk degrades to the
|
||||
// default rather than rendering a broken button row.
|
||||
_quickPointAmounts() {
|
||||
const raw = (this._state.settings || {}).quick_point_amounts;
|
||||
const parsed = String(raw == null ? "" : raw)
|
||||
.split(",")
|
||||
.map(p => Number(p.trim()))
|
||||
.filter(n => Number.isInteger(n) && n >= 1 && n <= 10000);
|
||||
const unique = [...new Set(parsed)].slice(0, 3);
|
||||
return unique.length ? unique : [5, 10, 20];
|
||||
}
|
||||
|
||||
_mdi(name) {
|
||||
return `<ha-icon icon="${this._esc(name || "mdi:account-circle")}"></ha-icon>`;
|
||||
}
|
||||
@@ -6408,6 +6665,36 @@ class TaskMatePanel extends HTMLElement {
|
||||
}
|
||||
.tm-stat-highlight .tm-stat-value { color: var(--tm-gold); }
|
||||
|
||||
/* Manual point adjustment strip on child cards (#746). Each -set is a
|
||||
nowrap group so the row folds as "minus / plus + ⋯" on a narrow card
|
||||
instead of orphaning the ⋯ button on a line of its own. */
|
||||
/* Chore picture well (#750). */
|
||||
.tm-image-well {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px; border: 1px solid var(--tm-border);
|
||||
border-radius: var(--tm-radius-sm); background: var(--tm-surface-2);
|
||||
}
|
||||
.tm-image-thumb {
|
||||
width: 56px; height: 56px; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: var(--tm-radius-sm); display: block;
|
||||
border: 1px solid var(--tm-border);
|
||||
}
|
||||
.tm-image-empty {
|
||||
width: 56px; height: 56px; display: grid; place-items: center;
|
||||
border: 1px dashed var(--tm-border); border-radius: var(--tm-radius-sm);
|
||||
color: var(--tm-text-faint); font-size: 11px; text-align: center;
|
||||
}
|
||||
.tm-image-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.tm-points-adjust {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.tm-points-adjust-set { display: flex; gap: 4px; align-items: center; }
|
||||
.tm-points-adjust-gap { flex: 1 1 8px; min-width: 4px; }
|
||||
.tm-points-adjust .tm-btn { min-width: 40px; justify-content: center; padding-inline: 8px; }
|
||||
|
||||
.tm-card-foot {
|
||||
display: flex; gap: 6px;
|
||||
margin-top: auto;
|
||||
|
||||
@@ -224,6 +224,20 @@ class TaskMateReorderCard extends LitElement {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Uploaded chore picture (#750). Sized explicitly, NOT width:100%:
|
||||
.chore-icon is applied straight to the element with no wrapper box, so
|
||||
a percentage would resolve against the flex row and stretch the photo
|
||||
across it. min-*:0 stops a portrait photo overflowing the square slot
|
||||
via the grid/flex min-*:auto intrinsic-ratio minimum. */
|
||||
.chore-icon-img {
|
||||
width: 24px; height: 24px; min-width: 0; min-height: 0;
|
||||
flex: 0 0 24px;
|
||||
object-fit: cover; border-radius: 6px; display: block;
|
||||
}
|
||||
/* The designed row nests the glyph in a content-sized <span class="d-emoji">. */
|
||||
.d-emoji .chore-icon-img {
|
||||
width: 20px; height: 20px; flex: 0 0 20px; border-radius: 5px;
|
||||
}
|
||||
.chore-icon {
|
||||
--mdc-icon-size: 24px;
|
||||
color: var(--secondary-text-color);
|
||||
@@ -911,10 +925,13 @@ class TaskMateReorderCard extends LitElement {
|
||||
_renderDesignedChoreItem(chore, index, total, category, pointsIcon) {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === total - 1;
|
||||
const choreIcon = chore.icon || "mdi:broom";
|
||||
const emoji = choreIcon.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${choreIcon}"></ha-icon>`
|
||||
: html`${choreIcon}`;
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
const choreIcon = (v.kind === "icon" ? v.icon : "") || "mdi:broom";
|
||||
const emoji = v.kind === "image"
|
||||
? html`<img class="chore-icon-img" src="${v.url}" alt="" loading="lazy">`
|
||||
: choreIcon.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${choreIcon}"></ha-icon>`
|
||||
: html`${choreIcon}`;
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -972,7 +989,12 @@ class TaskMateReorderCard extends LitElement {
|
||||
<ha-icon icon="mdi:drag-vertical"></ha-icon>
|
||||
</div>
|
||||
<span class="order-number">${index + 1}</span>
|
||||
<ha-icon class="chore-icon" icon="${chore.icon || "mdi:broom"}"></ha-icon>
|
||||
${(() => {
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
return v.kind === "image"
|
||||
? html`<img class="chore-icon chore-icon-img" src="${v.url}" alt="" loading="lazy">`
|
||||
: html`<ha-icon class="chore-icon" icon="${v.kind === "icon" ? v.icon : "mdi:broom"}"></ha-icon>`;
|
||||
})()}
|
||||
<div class="chore-info">
|
||||
<span class="chore-name">${chore.name}</span>
|
||||
<span class="chore-points">
|
||||
|
||||
@@ -275,7 +275,12 @@ class TaskMateRoutineCard extends LitElement {
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="icon"><ha-icon icon="${chore.icon || "mdi:checkbox-marked-circle-outline"}"></ha-icon></div>
|
||||
${(() => {
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
return v.kind === "image"
|
||||
? html`<div class="icon"><img class="chore-icon-img" src="${v.url}" alt="" loading="lazy"></div>`
|
||||
: html`<div class="icon"><ha-icon icon="${v.kind === "icon" ? v.icon : "mdi:checkbox-marked-circle-outline"}"></ha-icon></div>`;
|
||||
})()}
|
||||
<div class="task">${chore.name}</div>
|
||||
${chore.description ? html`<div class="desc">${chore.description}</div>` : ""}
|
||||
<div class="points">
|
||||
@@ -441,6 +446,13 @@ class TaskMateRoutineCard extends LitElement {
|
||||
padding: 20px 26px 8px;
|
||||
gap: 14px;
|
||||
}
|
||||
/* Uploaded chore picture (#750). .icon IS a sized box (128px, 104px on
|
||||
small screens), so 100% is correct here. min-*:0 stops a portrait
|
||||
photo overflowing it via the grid min-*:auto intrinsic-ratio minimum. */
|
||||
.icon .chore-icon-img {
|
||||
width: 100%; height: 100%; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: inherit; display: block;
|
||||
}
|
||||
.icon {
|
||||
width: 128px; height: 128px;
|
||||
border-radius: var(--tmd-radius, 34px);
|
||||
|
||||
Reference in New Issue
Block a user