@@ -107,3 +109,23 @@ export default function VerifyTwoFactor(props: Props) {
);
}
+
+const IndividualInput = styled("input")(
+ ({ theme }) => `
+ font-size: 1.5rem;
+ padding: 4px;
+ width: 40px !important;
+ aspect-ratio: 1;
+ margin-inline: 8px;
+ border: 1px solid ${theme.colors.accent.A700};
+ border-radius: 1px;
+ outline-color: ${theme.colors.accent.A300};
+ transition: 0.5s;
+
+ ${theme.breakpoints.down("sm")} {
+ font-size: 1rem;
+ padding: 4px;
+ width: 32px !important;
+ }
+`,
+);
diff --git a/web/packages/accounts/package.json b/web/packages/accounts/package.json
index 574276df1a..c5f7b0b881 100644
--- a/web/packages/accounts/package.json
+++ b/web/packages/accounts/package.json
@@ -5,6 +5,7 @@
"dependencies": {
"@/base": "*",
"@ente/eslint-config": "*",
- "@ente/shared": "*"
+ "@ente/shared": "*",
+ "react-otp-input": "^3.1.1"
}
}
diff --git a/web/packages/base/components/EnteDrawer.tsx b/web/packages/base/components/EnteDrawer.tsx
deleted file mode 100644
index e6fc35bb15..0000000000
--- a/web/packages/base/components/EnteDrawer.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Drawer, styled } from "@mui/material";
-
-export const EnteDrawer = styled(Drawer)(({ theme }) => ({
- "& .MuiPaper-root": {
- maxWidth: "375px",
- width: "100%",
- scrollbarWidth: "thin",
- padding: theme.spacing(1),
- },
-}));
diff --git a/web/packages/base/components/mui/SidebarDrawer.tsx b/web/packages/base/components/mui/SidebarDrawer.tsx
new file mode 100644
index 0000000000..93ef27599c
--- /dev/null
+++ b/web/packages/base/components/mui/SidebarDrawer.tsx
@@ -0,0 +1,17 @@
+import { Drawer, styled } from "@mui/material";
+
+/**
+ * A MUI {@link Drawer} with a standard set of styling that we use for our left
+ * and right sidebar panels.
+ *
+ * It is width limited to 375px, and always at full width. It also has a default
+ * padding.
+ */
+export const SidebarDrawer = styled(Drawer)(({ theme }) => ({
+ "& .MuiPaper-root": {
+ maxWidth: "375px",
+ width: "100%",
+ scrollbarWidth: "thin",
+ padding: theme.spacing(1),
+ },
+}));
diff --git a/web/packages/base/locales/be-BY/translation.json b/web/packages/base/locales/be-BY/translation.json
index 8e990166e6..b3dc7f9b9b 100644
--- a/web/packages/base/locales/be-BY/translation.json
+++ b/web/packages/base/locales/be-BY/translation.json
@@ -4,18 +4,18 @@
"intro_slide_2_title": "",
"intro_slide_2": "",
"intro_slide_3_title": "",
- "intro_slide_3": "",
+ "intro_slide_3": "Android, iOS, Інтэрнэт, Камп’ютар",
"login": "Увайсці",
"sign_up": "Рэгістрацыя",
- "NEW_USER": "",
- "EXISTING_USER": "",
- "enter_name": "",
+ "NEW_USER": "Новы ў Ente",
+ "EXISTING_USER": "Існуючы карыстальнік",
+ "enter_name": "Увядзіце імя",
"PUBLIC_UPLOADER_NAME_MESSAGE": "",
- "ENTER_EMAIL": "",
- "EMAIL_ERROR": "",
- "required": "",
+ "ENTER_EMAIL": "Увядзіце адрас электроннай пошты",
+ "EMAIL_ERROR": "Увядзіце сапраўдны адрас электроннай пошты",
+ "required": "патрабуецца",
"EMAIL_SENT": "",
- "CHECK_INBOX": "",
+ "CHECK_INBOX": "Праверце свае ўваходныя лісты (і спам) для завяршэння праверкі",
"ENTER_OTT": "Код пацвярджэння",
"RESEND_MAIL": "Паўторна адправіць код",
"VERIFY": "",
@@ -28,14 +28,14 @@
"password": "Пароль",
"link_password_description": "",
"unlock": "Разблакіраваць",
- "SET_PASSPHRASE": "",
+ "SET_PASSPHRASE": "Задаць пароль",
"VERIFY_PASSPHRASE": "Увайсці",
"INCORRECT_PASSPHRASE": "Няправільны пароль",
"ENTER_ENC_PASSPHRASE": "",
"PASSPHRASE_DISCLAIMER": "",
"key_generation_in_progress": "",
"PASSPHRASE_HINT": "Пароль",
- "CONFIRM_PASSPHRASE": "",
+ "CONFIRM_PASSPHRASE": "Пацвердзіць пароль",
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
@@ -45,17 +45,17 @@
"create_albums": "",
"enter_album_name": "",
"close_key": "",
- "enter_file_name": "",
+ "enter_file_name": "Назва файла",
"close": "Закрыць",
"no": "Не",
"nothing_here": "",
- "upload": "",
- "import": "",
- "add_photos": "",
- "add_more_photos": "",
+ "upload": "Запампаваць",
+ "import": "Імпартаваць",
+ "add_photos": "Дадаць фота",
+ "add_more_photos": "Дадаць больш фота",
"add_photos_count_one": "",
"add_photos_count": "",
- "select_photos": "",
+ "select_photos": "Абраць фота",
"FILE_UPLOAD": "",
"UPLOAD_STAGE_MESSAGE": {
"0": "",
diff --git a/web/packages/base/locales/lt-LT/translation.json b/web/packages/base/locales/lt-LT/translation.json
index 5d3c4252a4..94cd1f1c52 100644
--- a/web/packages/base/locales/lt-LT/translation.json
+++ b/web/packages/base/locales/lt-LT/translation.json
@@ -7,14 +7,14 @@
"intro_slide_3": "„Android“, „iOS“, internete, darbalaukyje",
"login": "Prisijungti",
"sign_up": "Registruotis",
- "NEW_USER": "Naujas platformoje „Ente“",
+ "NEW_USER": "Naujas sistemoje „Ente“",
"EXISTING_USER": "Esamas naudotojas",
"enter_name": "Įveskite vardą",
"PUBLIC_UPLOADER_NAME_MESSAGE": "Pridėkite vardą, kad draugai žinotų, kam padėkoti už šias puikias nuotraukas.",
"ENTER_EMAIL": "Įveskite el. pašto adresą",
"EMAIL_ERROR": "Įveskite tinkamą el. paštą.",
"required": "Privaloma",
- "EMAIL_SENT": "Patvirtinimo kodas išsiųstas į {{email}}",
+ "EMAIL_SENT": "Patvirtinimo kodas išsiųstas adresu {{email}}",
"CHECK_INBOX": "Patikrinkite savo gautieją (ir šlamštą), kad užbaigtumėte patvirtinimą",
"ENTER_OTT": "Patvirtinimo kodas",
"RESEND_MAIL": "Siųsti kodą iš naujo",
diff --git a/web/packages/base/locales/pt-PT/translation.json b/web/packages/base/locales/pt-PT/translation.json
index 307ed735d1..d8335ccf09 100644
--- a/web/packages/base/locales/pt-PT/translation.json
+++ b/web/packages/base/locales/pt-PT/translation.json
@@ -222,8 +222,8 @@
"photos_count": "{{count, number}} memórias",
"terms_and_conditions": "Eu concordo com os termos de serviço e política de privacidade",
"SELECTED": "selecionado",
- "people": "",
- "indexing_scheduled": "",
+ "people": "Pessoas",
+ "indexing_scheduled": "Indexação está programada...",
"indexing_photos": "Indexar fotos ({{nSyncedFiles, number}} / {{nTotalFiles, number}})",
"indexing_fetching": "Obtendo índices ({{nSyncedFiles, number}} / {{nTotalFiles, number}})",
"indexing_people": "Indexar pessoas em {{nSyncedFiles, number}} fotos...",
@@ -288,312 +288,312 @@
"ETAGS_BLOCKED": "Não foi possível fazer o envio dos seguintes arquivos devido à configuração do seu navegador.
Por favor, desative quaisquer complementos que possam estar impedindo o ente de utilizar eTags para enviar arquivos grandes, ou utilize nosso aplicativo para computador para uma experiência de importação mais confiável.
",
"LIVE_PHOTOS_DETECTED": "Os ficheiros de fotografia e vídeo das suas Live Photos foram fundidos num único ficheiro",
"RETRY_FAILED": "Repetir envios com falha",
- "FAILED_UPLOADS": "",
- "failed_uploads_hint": "",
- "SKIPPED_FILES": "",
- "THUMBNAIL_GENERATION_FAILED_UPLOADS": "",
- "UNSUPPORTED_FILES": "",
- "SUCCESSFUL_UPLOADS": "",
- "SKIPPED_INFO": "",
- "UNSUPPORTED_INFO": "",
- "BLOCKED_UPLOADS": "",
- "INPROGRESS_METADATA_EXTRACTION": "",
- "INPROGRESS_UPLOADS": "",
- "TOO_LARGE_UPLOADS": "",
- "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "",
- "LARGER_THAN_AVAILABLE_STORAGE_INFO": "",
- "TOO_LARGE_INFO": "",
- "THUMBNAIL_GENERATION_FAILED_INFO": "",
- "upload_to_album": "",
- "add_to_album": "",
- "move_to_album": "",
- "unhide_to_album": "",
- "restore_to_album": "",
- "section_all": "",
- "section_uncategorized": "",
- "section_archive": "",
- "section_hidden": "",
- "section_trash": "",
- "favorites": "",
- "archive": "",
- "archive_album": "",
- "unarchive": "",
- "unarchive_album": "",
- "hide_collection": "",
- "unhide_collection": "",
- "MOVE": "",
- "add": "",
- "REMOVE": "",
- "YES_REMOVE": "",
- "REMOVE_FROM_COLLECTION": "",
- "MOVE_TO_TRASH": "",
- "TRASH_FILES_MESSAGE": "",
- "TRASH_FILE_MESSAGE": "",
- "DELETE_PERMANENTLY": "",
- "RESTORE": "",
- "empty_trash": "",
- "empty_trash_title": "",
- "empty_trash_message": "",
- "leave_album": "",
- "leave_shared_album_title": "",
- "leave_shared_album_message": "",
- "leave_shared_album": "",
- "NOT_FILE_OWNER": "",
- "CONFIRM_SELF_REMOVE_MESSAGE": "",
- "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "",
- "sort_by_creation_time_ascending": "",
- "sort_by_updation_time_descending": "",
- "sort_by_name": "",
- "FIX_CREATION_TIME": "",
- "FIX_CREATION_TIME_IN_PROGRESS": "",
- "CREATION_TIME_UPDATED": "",
- "UPDATE_CREATION_TIME_NOT_STARTED": "",
- "UPDATE_CREATION_TIME_COMPLETED": "",
- "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "",
- "CAPTION_CHARACTER_LIMIT": "",
- "DATE_TIME_ORIGINAL": "",
- "DATE_TIME_DIGITIZED": "",
- "METADATA_DATE": "",
- "CUSTOM_TIME": "",
- "sharing_details": "",
- "modify_sharing": "",
- "ADD_COLLABORATORS": "",
- "ADD_NEW_EMAIL": "",
- "shared_with_people_count_zero": "",
- "shared_with_people_count_one": "",
- "shared_with_people_count": "",
- "participants_count_zero": "",
- "participants_count_one": "",
- "participants_count": "",
- "ADD_VIEWERS": "",
- "CHANGE_PERMISSIONS_TO_VIEWER": "",
- "CHANGE_PERMISSIONS_TO_COLLABORATOR": "",
- "CONVERT_TO_VIEWER": "",
- "CONVERT_TO_COLLABORATOR": "",
- "CHANGE_PERMISSION": "",
- "REMOVE_PARTICIPANT": "",
- "CONFIRM_REMOVE": "",
- "MANAGE": "",
- "ADDED_AS": "",
- "COLLABORATOR_RIGHTS": "",
- "REMOVE_PARTICIPANT_HEAD": "",
- "OWNER": "",
- "COLLABORATORS": "",
- "ADD_MORE": "",
- "VIEWERS": "",
- "OR_ADD_EXISTING": "",
- "REMOVE_PARTICIPANT_MESSAGE": "",
- "NOT_FOUND": "",
- "LINK_EXPIRED": "",
- "LINK_EXPIRED_MESSAGE": "",
- "MANAGE_LINK": "",
- "LINK_TOO_MANY_REQUESTS": "",
- "FILE_DOWNLOAD": "",
- "link_password_lock": "",
- "PUBLIC_COLLECT": "",
- "LINK_DEVICE_LIMIT": "",
- "NO_DEVICE_LIMIT": "",
- "LINK_EXPIRY": "",
- "NEVER": "",
- "DISABLE_FILE_DOWNLOAD": "",
- "DISABLE_FILE_DOWNLOAD_MESSAGE": "",
- "SHARED_USING": "",
- "SHARING_REFERRAL_CODE": "",
- "LIVE": "",
- "DISABLE_PASSWORD": "",
- "DISABLE_PASSWORD_MESSAGE": "",
- "PASSWORD_LOCK": "",
- "LOCK": "",
- "file": "",
- "folder": "",
- "google_takeout": "",
- "DEDUPLICATE_FILES": "",
- "NO_DUPLICATES_FOUND": "",
- "FILES": "",
- "EACH": "",
- "DEDUPLICATE_BASED_ON_SIZE": "",
- "STOP_ALL_UPLOADS_MESSAGE": "",
- "STOP_UPLOADS_HEADER": "",
- "YES_STOP_UPLOADS": "",
- "STOP_DOWNLOADS_HEADER": "",
- "YES_STOP_DOWNLOADS": "",
- "STOP_ALL_DOWNLOADS_MESSAGE": "",
- "albums": "",
- "albums_count_one": "",
- "albums_count": "",
- "all_albums": "",
- "all_hidden_albums": "",
- "hidden_albums": "",
- "hidden_items": "",
- "ENTER_TWO_FACTOR_OTP": "",
- "create_account": "",
- "COPIED": "",
- "WATCH_FOLDERS": "",
- "upgrade_now": "",
- "renew_now": "",
- "STORAGE": "",
- "USED": "",
- "YOU": "",
- "FAMILY": "",
- "FREE": "",
- "OF": "",
- "WATCHED_FOLDERS": "",
- "NO_FOLDERS_ADDED": "",
- "FOLDERS_AUTOMATICALLY_MONITORED": "",
- "UPLOAD_NEW_FILES_TO_ENTE": "",
- "REMOVE_DELETED_FILES_FROM_ENTE": "",
- "ADD_FOLDER": "",
- "STOP_WATCHING": "",
- "STOP_WATCHING_FOLDER": "",
- "STOP_WATCHING_DIALOG_MESSAGE": "",
- "YES_STOP": "",
- "CHANGE_FOLDER": "",
- "FAMILY_PLAN": "",
- "debug_logs": "",
- "download_logs": "",
- "download_logs_message": "",
- "WEAK_DEVICE": "",
- "drag_and_drop_hint": "",
- "AUTHENTICATE": "",
- "UPLOADED_TO_SINGLE_COLLECTION": "",
- "UPLOADED_TO_SEPARATE_COLLECTIONS": "",
- "NEVERMIND": "",
- "update_available": "",
- "update_installable_message": "",
- "install_now": "",
- "install_on_next_launch": "",
- "update_available_message": "",
- "download_and_install": "",
- "ignore_this_version": "",
- "TODAY": "",
- "YESTERDAY": "",
- "NAME_PLACEHOLDER": "",
- "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "",
- "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "",
- "CHOSE_THEME": "",
- "more_details": "",
- "ml_search": "",
- "ml_search_description": "",
- "ml_search_footnote": "",
- "indexing": "",
- "processed": "",
- "indexing_status_running": "",
- "indexing_status_fetching": "",
- "indexing_status_scheduled": "",
- "indexing_status_done": "",
- "ml_search_disable": "",
- "ml_search_disable_confirm": "",
- "ml_consent": "",
- "ml_consent_title": "",
- "ml_consent_description": "",
- "ml_consent_confirmation": "",
- "labs": "",
- "YOURS": "",
- "passphrase_strength_weak": "",
- "passphrase_strength_moderate": "",
- "passphrase_strength_strong": "",
- "preferences": "",
- "language": "",
- "advanced": "",
- "EXPORT_DIRECTORY_DOES_NOT_EXIST": "",
- "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "",
- "SUBSCRIPTION_VERIFICATION_ERROR": "",
+ "FAILED_UPLOADS": "Upload falhou ",
+ "failed_uploads_hint": "Haverá uma opção para tentar novamente quando o upload terminar",
+ "SKIPPED_FILES": "Uploads ignorados",
+ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Falha ao gerar miniaturas",
+ "UNSUPPORTED_FILES": "Arquivos não suportados",
+ "SUCCESSFUL_UPLOADS": "Envios bem sucedidos",
+ "SKIPPED_INFO": "Saltou estes ficheiros porque existem ficheiros com o mesmo nome e conteúdo no mesmo álbum",
+ "UNSUPPORTED_INFO": "Ente ainda não suporta estes formatos de arquivo",
+ "BLOCKED_UPLOADS": "Uploads bloqueados",
+ "INPROGRESS_METADATA_EXTRACTION": "Em andamento",
+ "INPROGRESS_UPLOADS": "Uploads em andamento",
+ "TOO_LARGE_UPLOADS": "Arquivos grandes",
+ "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "Armazenamento insuficiente",
+ "LARGER_THAN_AVAILABLE_STORAGE_INFO": "Estes ficheiros não foram carregados porque excedem o limite máximo de tamanho do seu plano de armazenamento",
+ "TOO_LARGE_INFO": "Estes ficheiros não foram carregados porque excedem o nosso limite máximo de tamanho de ficheiro",
+ "THUMBNAIL_GENERATION_FAILED_INFO": "Estes ficheiros foram carregados, mas infelizmente não foi possível gerar as respectivas miniaturas.",
+ "upload_to_album": "Carregar para o álbum",
+ "add_to_album": "Adicionar ao álbum",
+ "move_to_album": "Mover para álbum",
+ "unhide_to_album": "Mostrar para o álbum",
+ "restore_to_album": "Restaurar para álbum",
+ "section_all": "Todos",
+ "section_uncategorized": "Sem categoria",
+ "section_archive": "Arquivado",
+ "section_hidden": "Oculto",
+ "section_trash": "Lixo",
+ "favorites": "Favoritos",
+ "archive": "Arquivar",
+ "archive_album": "Arquivar álbum",
+ "unarchive": "Desarquivar",
+ "unarchive_album": "Desarquivar álbum",
+ "hide_collection": "Ocultar álbum",
+ "unhide_collection": "Mostrar álbum",
+ "MOVE": "Mover",
+ "add": "Adicionar",
+ "REMOVE": "Remover",
+ "YES_REMOVE": "Sim, remover",
+ "REMOVE_FROM_COLLECTION": "Remover do álbum",
+ "MOVE_TO_TRASH": "Mover para o lixo",
+ "TRASH_FILES_MESSAGE": "Os ficheiros selecionados serão removidos de todos os álbuns e movidos para o lixo.",
+ "TRASH_FILE_MESSAGE": "O ficheiro será removido de todos os álbuns e movido para o lixo.",
+ "DELETE_PERMANENTLY": "Apagar permanentemente",
+ "RESTORE": "Restaurar",
+ "empty_trash": "Esvaziar lixo",
+ "empty_trash_title": "Esvaziar lixo?",
+ "empty_trash_message": "Estes ficheiros serão permanentemente eliminados da sua conta Ente.",
+ "leave_album": "Sair do álbum",
+ "leave_shared_album_title": "Sair do álbum compartilhado?",
+ "leave_shared_album_message": "Sairá do álbum e este deixará de ser visível para si.",
+ "leave_shared_album": "Sim, sair",
+ "NOT_FILE_OWNER": "Não é possível apagar ficheiros de um álbum partilhado",
+ "CONFIRM_SELF_REMOVE_MESSAGE": "Os itens selecionados serão removidos deste álbum. Os itens que estão apenas neste álbum serão movidos para Uncategorized.",
+ "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "Alguns dos itens que está a remover foram adicionados por outras pessoas, pelo que perderá o acesso aos mesmos.",
+ "sort_by_creation_time_ascending": "Mais antigo",
+ "sort_by_updation_time_descending": "Última atualização",
+ "sort_by_name": "Nome",
+ "FIX_CREATION_TIME": "Corrigir hora",
+ "FIX_CREATION_TIME_IN_PROGRESS": "Corrigindo horário",
+ "CREATION_TIME_UPDATED": "Hora do arquivo atualizado",
+ "UPDATE_CREATION_TIME_NOT_STARTED": "Selecione a opção que deseja usar",
+ "UPDATE_CREATION_TIME_COMPLETED": "Todos os arquivos atualizados com sucesso",
+ "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "A atualização do horário falhou para alguns arquivos, por favor, tente novamente",
+ "CAPTION_CHARACTER_LIMIT": "5000 caracteres no máximo",
+ "DATE_TIME_ORIGINAL": "Exif: Data e Hora Original",
+ "DATE_TIME_DIGITIZED": "Exif: Data e Hora Digitalizada",
+ "METADATA_DATE": "Exif: Data de Metadados",
+ "CUSTOM_TIME": "Tempo personalizado",
+ "sharing_details": "Detalhes de compartilhamento",
+ "modify_sharing": "Modificar compartilhamento",
+ "ADD_COLLABORATORS": "Adicionar colaboradores",
+ "ADD_NEW_EMAIL": "Adicionar um novo email",
+ "shared_with_people_count_zero": "Partilhar com pessoas específicas",
+ "shared_with_people_count_one": "Partilhado com 1 pessoa",
+ "shared_with_people_count": "Partilhado com {{count, number}} pessoas",
+ "participants_count_zero": "Nenhum participante",
+ "participants_count_one": "1 participante",
+ "participants_count": "{{count, number}} participantes",
+ "ADD_VIEWERS": "Adicionar visualizações",
+ "CHANGE_PERMISSIONS_TO_VIEWER": "{{selectedEmail}} não poderá adicionar mais fotografias ao álbum
Ainda poderão remover fotografias adicionadas por eles
",
+ "CHANGE_PERMISSIONS_TO_COLLABORATOR": "{{selectedEmail}} poderá adicionar fotografias ao álbum",
+ "CONVERT_TO_VIEWER": "Sim, converter para visualizador",
+ "CONVERT_TO_COLLABORATOR": "Sim, converter para colaborador",
+ "CHANGE_PERMISSION": "Alterar permissões?",
+ "REMOVE_PARTICIPANT": "Remover?",
+ "CONFIRM_REMOVE": "Sim, remover",
+ "MANAGE": "Gerenciar",
+ "ADDED_AS": "Adicionado como",
+ "COLLABORATOR_RIGHTS": "Os colaboradores podem adicionar fotografias e vídeos ao álbum partilhado",
+ "REMOVE_PARTICIPANT_HEAD": "Remover participante",
+ "OWNER": "Proprietário",
+ "COLLABORATORS": "Colaboradores",
+ "ADD_MORE": "Adicionar mais",
+ "VIEWERS": "Visualizadores",
+ "OR_ADD_EXISTING": "Ou escolher um já existente",
+ "REMOVE_PARTICIPANT_MESSAGE": "{{selectedEmail}} será removido do álbum
Quaisquer fotografias adicionadas por ele também serão removidas do álbum
",
+ "NOT_FOUND": "404 Página não encontrada",
+ "LINK_EXPIRED": "Link expirado",
+ "LINK_EXPIRED_MESSAGE": "Este link expirou ou foi desativado!",
+ "MANAGE_LINK": "Gerir link",
+ "LINK_TOO_MANY_REQUESTS": "Desculpe, este álbum foi visualizado em muitos dispositivos!",
+ "FILE_DOWNLOAD": "Permitir downloads",
+ "link_password_lock": "Bloqueio da palavra-passe",
+ "PUBLIC_COLLECT": "Permitir adicionar fotos",
+ "LINK_DEVICE_LIMIT": "Limite de dispositivos",
+ "NO_DEVICE_LIMIT": "Nenhum",
+ "LINK_EXPIRY": "Link expirado",
+ "NEVER": "Nunca",
+ "DISABLE_FILE_DOWNLOAD": "Desativar download",
+ "DISABLE_FILE_DOWNLOAD_MESSAGE": "Tem a certeza de que pretende desativar o botão de transferência de ficheiros?
Os espectadores podem ainda tirar capturas de ecrã ou guardar uma cópia das suas fotografias utilizando ferramentas externas.
",
+ "SHARED_USING": "Partilhado utilizando ",
+ "SHARING_REFERRAL_CODE": "Use o código {{referralCode}} para obter 10 GB de graça",
+ "LIVE": "EM DIRETO",
+ "DISABLE_PASSWORD": "Desativar o bloqueio da palavra-passe",
+ "DISABLE_PASSWORD_MESSAGE": "Tem a certeza de que pretende desativar o bloqueio de palavra-passe?",
+ "PASSWORD_LOCK": "Bloqueio da palavra-passe",
+ "LOCK": "Bloquear",
+ "file": "Arquivo",
+ "folder": "Pasta",
+ "google_takeout": "Google Takeout",
+ "DEDUPLICATE_FILES": "Arquivos duplicados",
+ "NO_DUPLICATES_FOUND": "Não existem ficheiros duplicados que possam ser eliminados",
+ "FILES": "arquivos",
+ "EACH": "cada",
+ "DEDUPLICATE_BASED_ON_SIZE": "Os seguintes ficheiros foram agrupados com base nos seus tamanhos. Reveja e elimine os itens que considera duplicados",
+ "STOP_ALL_UPLOADS_MESSAGE": "Tem a certeza de que pretende parar todos os carregamentos em curso?",
+ "STOP_UPLOADS_HEADER": "Parar uploads?",
+ "YES_STOP_UPLOADS": "Sim, parar uploads",
+ "STOP_DOWNLOADS_HEADER": "Parar downloads?",
+ "YES_STOP_DOWNLOADS": "Sim, parar downloads",
+ "STOP_ALL_DOWNLOADS_MESSAGE": "Tem a certeza de que pretende parar todas as transferências em curso?",
+ "albums": "Álbuns",
+ "albums_count_one": "1 Álbum",
+ "albums_count": "{{count, number}} Álbuns",
+ "all_albums": "Todos os álbuns",
+ "all_hidden_albums": "Todos os álbuns ocultos",
+ "hidden_albums": "Álbuns ocultos",
+ "hidden_items": "Itens ocultos",
+ "ENTER_TWO_FACTOR_OTP": "Introduzir o código de 6 dígitos da\nsua aplicação de autenticação.",
+ "create_account": "Criar conta",
+ "COPIED": "Copiado",
+ "WATCH_FOLDERS": "Pastas monitoradas",
+ "upgrade_now": "Atualizar agora",
+ "renew_now": "Renovar agora",
+ "STORAGE": "Armazenamento",
+ "USED": "utilizado",
+ "YOU": "Tu",
+ "FAMILY": "Família",
+ "FREE": "grátis",
+ "OF": "de",
+ "WATCHED_FOLDERS": "Pastas monitoradas",
+ "NO_FOLDERS_ADDED": "Nenhuma pasta adicionada ainda!",
+ "FOLDERS_AUTOMATICALLY_MONITORED": "As pastas que adicionar aqui serão monitorizadas automaticamente",
+ "UPLOAD_NEW_FILES_TO_ENTE": "Carregar novos ficheiros para o Ente",
+ "REMOVE_DELETED_FILES_FROM_ENTE": "Remover ficheiros eliminados do Ente",
+ "ADD_FOLDER": "Adicionar pasta",
+ "STOP_WATCHING": "Parar de assistir",
+ "STOP_WATCHING_FOLDER": "Deixar de ver a pasta?",
+ "STOP_WATCHING_DIALOG_MESSAGE": "Os seus ficheiros existentes não serão eliminados, mas o Ente deixará de atualizar automaticamente o álbum Ente associado às alterações nesta pasta.",
+ "YES_STOP": "Sim, parar",
+ "CHANGE_FOLDER": "Alterar pasta",
+ "FAMILY_PLAN": "Plano familiar",
+ "debug_logs": "Logs de depuração",
+ "download_logs": "Descarregar logs",
+ "download_logs_message": "Isto irá descarregar registos de depuração, que pode enviar-nos por correio eletrónico para ajudar a depurar o seu problema.
Por favor, note que os nomes dos ficheiros serão incluídos para ajudar a localizar problemas com ficheiros específicos.
",
+ "WEAK_DEVICE": "O navegador Web que está a utilizar não é suficientemente potente para encriptar as suas fotografias. Tente iniciar sessão no Ente no seu computador ou descarregue a aplicação móvel/desktop do Ente.",
+ "drag_and_drop_hint": "Ou arrastar e largar na janela Ente",
+ "AUTHENTICATE": "Autenticar",
+ "UPLOADED_TO_SINGLE_COLLECTION": "Carregado para uma coleção única",
+ "UPLOADED_TO_SEPARATE_COLLECTIONS": "Carregado para colecções separadas",
+ "NEVERMIND": "Esquecer",
+ "update_available": "Atualização disponível",
+ "update_installable_message": "Uma nova versão do Ente está pronta para ser instalada.",
+ "install_now": "Instalar agora",
+ "install_on_next_launch": "Instalar na próxima inicialização",
+ "update_available_message": "Foi lançada uma nova versão do Ente, mas não pode ser descarregada e instalada automaticamente.",
+ "download_and_install": "Descarregar e instalar",
+ "ignore_this_version": "Ignorar esta versão",
+ "TODAY": "Hoje",
+ "YESTERDAY": "Ontem",
+ "NAME_PLACEHOLDER": "Nome...",
+ "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "Não foi possível criar álbuns a partir da mistura de arquivos/pastas",
+ "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "Arrastou e largou uma mistura de ficheiros e pastas.
Por favor, forneça apenas ficheiros ou apenas pastas quando selecionar a opção para criar álbuns separados
",
+ "CHOSE_THEME": "Escolher tema",
+ "more_details": "Mais detalhes",
+ "ml_search": "Aprendizagem automática",
+ "ml_search_description": "O Ente suporta a aprendizagem automática no dispositivo para reconhecimento facial, pesquisa mágica e outras funcionalidades de pesquisa avançadas",
+ "ml_search_footnote": "A pesquisa mágica permite pesquisar fotografias pelo seu conteúdo, por exemplo, “carro”, “carro vermelho”, “Ferrari",
+ "indexing": "Indexar",
+ "processed": "Processado",
+ "indexing_status_running": "Em execução",
+ "indexing_status_fetching": "A procurar",
+ "indexing_status_scheduled": "Agendado",
+ "indexing_status_done": "Concluído",
+ "ml_search_disable": "Desativar aprendizado automático",
+ "ml_search_disable_confirm": "Pretende desativar a aprendizagem automática em todos os seus dispositivos?",
+ "ml_consent": "Ativar aprendizagem automática",
+ "ml_consent_title": "Ativar aprendizagem automática?",
+ "ml_consent_description": "Se ativar a aprendizagem automática, o Ente extrairá informações como a geometria do rosto de ficheiros, incluindo os partilhados consigo.
Isto acontecerá no seu dispositivo e qualquer informação biométrica gerada será encriptada de ponta a ponta.
Clique aqui para obter mais detalhes sobre esta funcionalidade na nossa política de privacidade
",
+ "ml_consent_confirmation": "Eu entendo, e desejo ativar a aprendizagem automática",
+ "labs": "Laboratórios",
+ "YOURS": "seu",
+ "passphrase_strength_weak": "Força da palavra-passe: Fraca",
+ "passphrase_strength_moderate": "Força da palavra-passe: Moderada",
+ "passphrase_strength_strong": "Força da palavra-passe: Forte",
+ "preferences": "Preferências",
+ "language": "Idioma",
+ "advanced": "Avançado",
+ "EXPORT_DIRECTORY_DOES_NOT_EXIST": "Diretório de exportação inválido",
+ "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "O diretório de exportação que selecionou não existe.
Por favor, selecione um diretório válido.
",
+ "SUBSCRIPTION_VERIFICATION_ERROR": "Falha na verificação da subscrição",
"storage_unit": {
- "b": "",
- "kb": "",
- "mb": "",
- "gb": "",
- "tb": ""
+ "b": "B",
+ "kb": "KB",
+ "mb": "MB",
+ "gb": "GB",
+ "tb": "TB"
},
"AFTER_TIME": {
- "HOUR": "",
- "DAY": "",
- "WEEK": "",
- "MONTH": "",
- "YEAR": ""
+ "HOUR": "após uma hora",
+ "DAY": "após um dia",
+ "WEEK": "após uma semana",
+ "MONTH": "após um mês",
+ "YEAR": "após um ano"
},
- "COPY_LINK": "",
- "DONE": "",
- "LINK_SHARE_TITLE": "",
- "REMOVE_LINK": "",
- "CREATE_PUBLIC_SHARING": "",
- "PUBLIC_LINK_CREATED": "",
- "PUBLIC_LINK_ENABLED": "",
- "COLLECT_PHOTOS": "",
- "PUBLIC_COLLECT_SUBTEXT": "",
- "STOP_EXPORT": "",
- "EXPORT_PROGRESS": "",
- "MIGRATING_EXPORT": "",
- "RENAMING_COLLECTION_FOLDERS": "",
- "TRASHING_DELETED_FILES": "",
- "TRASHING_DELETED_COLLECTIONS": "",
- "CONTINUOUS_EXPORT": "",
- "PENDING_ITEMS": "",
- "EXPORT_STARTING": "",
- "delete_account_reason_label": "",
- "delete_account_reason_placeholder": "",
+ "COPY_LINK": "Copiar link",
+ "DONE": "Concluído",
+ "LINK_SHARE_TITLE": "Ou partilhar uma link",
+ "REMOVE_LINK": "Remover link",
+ "CREATE_PUBLIC_SHARING": "Criar link público",
+ "PUBLIC_LINK_CREATED": "Link público criado",
+ "PUBLIC_LINK_ENABLED": "Link público ativado",
+ "COLLECT_PHOTOS": "Recolher fotos",
+ "PUBLIC_COLLECT_SUBTEXT": "Permitir que as pessoas com a ligação também adicionem fotos ao álbum partilhado.",
+ "STOP_EXPORT": "Parar",
+ "EXPORT_PROGRESS": "{{progress.success, number}} / {{progress.total, number}} itens sincronizados",
+ "MIGRATING_EXPORT": "Preparar...",
+ "RENAMING_COLLECTION_FOLDERS": "Renomear pastas de álbuns...",
+ "TRASHING_DELETED_FILES": "Eliminar arquivos apagados...",
+ "TRASHING_DELETED_COLLECTIONS": "Eliminar álbuns apagados...",
+ "CONTINUOUS_EXPORT": "Sincronização contínua",
+ "PENDING_ITEMS": "Itens pendentes",
+ "EXPORT_STARTING": "Iniciar a exportação...",
+ "delete_account_reason_label": "Qual o principal motivo pelo qual está a eliminar a conta?",
+ "delete_account_reason_placeholder": "Selecione um motivo",
"delete_reason": {
- "missing_feature": "",
- "behaviour": "",
- "found_another_service": "",
- "not_listed": ""
+ "missing_feature": "Falta uma chave que eu preciso",
+ "behaviour": "O aplicativo ou um determinado recurso não se comportou como era suposto",
+ "found_another_service": "Encontrei outro serviço que gosto mais",
+ "not_listed": "O motivo não está na lista"
},
- "delete_account_feedback_label": "",
- "delete_account_feedback_placeholder": "",
- "delete_account_confirm_checkbox_label": "",
- "delete_account_confirm": "",
- "delete_account_confirm_message": "",
- "feedback_required": "",
- "feedback_required_found_another_service": "",
- "RECOVER_TWO_FACTOR": "",
- "at": "",
- "AUTH_NEXT": "",
- "AUTH_DOWNLOAD_MOBILE_APP": "",
- "HIDE": "",
- "UNHIDE": "",
- "sort_by": "",
- "newest_first": "",
- "oldest_first": "",
- "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "",
- "pin_album": "",
- "unpin_album": "",
- "DOWNLOAD_COMPLETE": "",
- "DOWNLOADING_COLLECTION": "",
- "DOWNLOAD_FAILED": "",
- "DOWNLOAD_PROGRESS": "",
- "CHRISTMAS": "",
- "CHRISTMAS_EVE": "",
- "NEW_YEAR": "",
- "NEW_YEAR_EVE": "",
- "IMAGE": "",
- "VIDEO": "",
- "LIVE_PHOTO": "",
+ "delete_account_feedback_label": "Lamentamos a sua partida. Indique-nos a razão para podermos melhorar o serviço.",
+ "delete_account_feedback_placeholder": "Feedback",
+ "delete_account_confirm_checkbox_label": "Sim, quero apagar permanentemente esta conta e todos os seus dados",
+ "delete_account_confirm": "Confirmar eliminação da conta",
+ "delete_account_confirm_message": "Esta conta está ligada a outras aplicações Ente, se utilizar alguma.
Os seus dados carregados, em todas as aplicações Ente, serão agendados para eliminação e a sua conta será permanentemente eliminada.
",
+ "feedback_required": "Por favor, ajude-nos com esta informação",
+ "feedback_required_found_another_service": "O que o outro serviço faz melhor?",
+ "RECOVER_TWO_FACTOR": "Recuperar dois fatores",
+ "at": "em",
+ "AUTH_NEXT": "seguinte",
+ "AUTH_DOWNLOAD_MOBILE_APP": "Descarregue a nossa aplicação móvel para gerir os seus segredos",
+ "HIDE": "Ocultar",
+ "UNHIDE": "Mostrar",
+ "sort_by": "Ordenar por",
+ "newest_first": "Mais recentes primeiro",
+ "oldest_first": "Mais antigo primeiro",
+ "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "Este arquivo não pôde ser visualizado. Clique aqui para fazer o download original.",
+ "pin_album": "Fixar álbum",
+ "unpin_album": "Desafixar álbum",
+ "DOWNLOAD_COMPLETE": "Download concluído",
+ "DOWNLOADING_COLLECTION": "Fazer download de {{name}}",
+ "DOWNLOAD_FAILED": "Falha no download",
+ "DOWNLOAD_PROGRESS": "{{progress.current}} / {{progress.total}} arquivos",
+ "CHRISTMAS": "Natal",
+ "CHRISTMAS_EVE": "Véspera de Natal",
+ "NEW_YEAR": "Ano Novo",
+ "NEW_YEAR_EVE": "Véspera de Ano Novo",
+ "IMAGE": "Imagem",
+ "VIDEO": "Vídeo",
+ "LIVE_PHOTO": "Fotos em movimento",
"editor": {
- "crop": ""
+ "crop": "Recortar"
},
- "CONVERT": "",
- "confirm_editor_close": "",
- "confirm_editor_close_message": "",
- "BRIGHTNESS": "",
- "CONTRAST": "",
- "SATURATION": "",
- "BLUR": "",
- "INVERT_COLORS": "",
- "ASPECT_RATIO": "",
- "SQUARE": "",
- "ROTATE_LEFT": "",
- "ROTATE_RIGHT": "",
- "FLIP_VERTICALLY": "",
- "FLIP_HORIZONTALLY": "",
- "DOWNLOAD_EDITED": "",
- "SAVE_A_COPY_TO_ENTE": "",
- "RESTORE_ORIGINAL": "",
- "TRANSFORM": "",
- "COLORS": "",
- "FLIP": "",
- "ROTATION": "",
- "reset": "",
- "PHOTO_EDITOR": "",
+ "CONVERT": "Converter",
+ "confirm_editor_close": "Tem certeza de que deseja fechar o editor?",
+ "confirm_editor_close_message": "Descarregue a imagem editada ou guarde uma cópia no Ente para manter as alterações.",
+ "BRIGHTNESS": "Brilho",
+ "CONTRAST": "Contraste",
+ "SATURATION": "Saturação",
+ "BLUR": "Desfoque",
+ "INVERT_COLORS": "Inverter Cores",
+ "ASPECT_RATIO": "Proporção da imagem",
+ "SQUARE": "Quadrado",
+ "ROTATE_LEFT": "Rodar para a esquerda",
+ "ROTATE_RIGHT": "Rodar para a direita",
+ "FLIP_VERTICALLY": "Inverter verticalmente",
+ "FLIP_HORIZONTALLY": "Inverter horizontalmente",
+ "DOWNLOAD_EDITED": "Descarregar Editado",
+ "SAVE_A_COPY_TO_ENTE": "Salvar uma cópia para o Ente",
+ "RESTORE_ORIGINAL": "Restaurar original",
+ "TRANSFORM": "Transformar",
+ "COLORS": "Cores",
+ "FLIP": "Inverter",
+ "ROTATION": "Rotação",
+ "reset": "Restaurar",
+ "PHOTO_EDITOR": "Editor de Fotos",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"cast_album_to_tv": "",
diff --git a/web/packages/new/photos/components/MLSettings.tsx b/web/packages/new/photos/components/MLSettings.tsx
index 8959be3c6a..3b2c43f44d 100644
--- a/web/packages/new/photos/components/MLSettings.tsx
+++ b/web/packages/new/photos/components/MLSettings.tsx
@@ -1,6 +1,6 @@
-import { EnteDrawer } from "@/base/components/EnteDrawer";
import { MenuItemGroup } from "@/base/components/Menu";
import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator";
+import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer";
import { Titlebar } from "@/base/components/Titlebar";
import type { NestedDrawerVisibilityProps } from "@/base/components/utils/modal";
import { disableML, enableML, type MLStatus } from "@/new/photos/services/ml";
@@ -67,7 +67,7 @@ export const MLSettings: React.FC = ({
return (
- = ({
/>
{component}
-
+
= ({
);
return (
- = ({
-
+
);
};
diff --git a/web/packages/new/photos/components/utils/use-loading-bar.ts b/web/packages/new/photos/components/utils/use-loading-bar.ts
new file mode 100644
index 0000000000..4c83a42349
--- /dev/null
+++ b/web/packages/new/photos/components/utils/use-loading-bar.ts
@@ -0,0 +1,26 @@
+import { useCallback, useRef } from "react";
+import { type LoadingBarRef } from "react-top-loading-bar";
+
+/**
+ * A convenience hook for returning stable functions tied to a
+ * {@link LoadingBar} ref.
+ *
+ * The {@link LoadingBar} component comes from the "react-top-loading-bar"
+ * library. To control it, we keep a ref. We want to allow components in our
+ * React tree to be able to also control the loading bar, but instead of
+ * exposing the ref directly, we export wrapper functions to start and stop the
+ * loading bar. This hook returns these functions (and the ref).
+ */
+export const useLoadingBar = () => {
+ const loadingBarRef = useRef();
+
+ const showLoadingBar = useCallback(() => {
+ loadingBarRef.current?.continuousStart();
+ }, []);
+
+ const hideLoadingBar = useCallback(() => {
+ loadingBarRef.current?.complete();
+ }, []);
+
+ return { loadingBarRef, showLoadingBar, hideLoadingBar };
+};
diff --git a/web/packages/new/photos/components/utils/use-wrap-async.ts b/web/packages/new/photos/components/utils/use-wrap-async.ts
index 473cd78dd6..bd77821586 100644
--- a/web/packages/new/photos/components/utils/use-wrap-async.ts
+++ b/web/packages/new/photos/components/utils/use-wrap-async.ts
@@ -15,18 +15,18 @@ import { useAppContext } from "../../types/context";
export const useWrapAsyncOperation = (
f: (...args: T) => Promise,
) => {
- const { startLoading, finishLoading, onGenericError } = useAppContext();
+ const { showLoadingBar, hideLoadingBar, onGenericError } = useAppContext();
return useCallback(
async (...args: T) => {
- startLoading();
+ showLoadingBar();
try {
await f(...args);
} catch (e) {
onGenericError(e);
} finally {
- finishLoading();
+ hideLoadingBar();
}
},
- [f, startLoading, finishLoading, onGenericError],
+ [f, showLoadingBar, hideLoadingBar, onGenericError],
);
};
diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts
deleted file mode 100644
index 64a343ad35..0000000000
--- a/web/packages/new/photos/services/exif-update.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { lowercaseExtension } from "@/base/file";
-import log from "@/base/log";
-import type { EnteFile } from "@/media/file";
-import { FileType } from "@/media/file-type";
-import piexif from "piexifjs";
-
-/**
- * Return a new stream after applying Exif updates if applicable to the given
- * stream, otherwise return the original.
- *
- * This function is meant to provide a stream that can be used to download (or
- * export) a file to the user's computer after applying any Exif updates to the
- * original file's data.
- *
- * - This only updates JPEG files.
- *
- * - For JPEG files, the DateTimeOriginal Exif entry is updated to reflect the
- * time that the user edited within Ente.
- *
- * @param file The {@link EnteFile} whose data we want.
- *
- * @param stream A {@link ReadableStream} containing the original data for
- * {@link file}.
- *
- * @returns A new {@link ReadableStream} with updates if any updates were
- * needed, otherwise return the original stream.
- */
-export const updateExifIfNeededAndPossible = async (
- file: EnteFile,
- stream: ReadableStream,
-): Promise> => {
- // Not needed: Not an image.
- if (file.metadata.fileType != FileType.image) return stream;
-
- // Not needed: Time was not edited.
- if (!file.pubMagicMetadata?.data.editedTime) return stream;
-
- const fileName = file.metadata.title;
- const extension = lowercaseExtension(fileName);
- // Not possible: Not a JPEG (likely).
- if (extension != "jpeg" && extension != "jpg") return stream;
-
- const blob = await new Response(stream).blob();
- try {
- const updatedBlob = await setJPEGExifDateTimeOriginal(
- blob,
- new Date(file.pubMagicMetadata.data.editedTime / 1000),
- );
- return updatedBlob.stream();
- } catch (e) {
- log.error(`Failed to modify Exif date for ${fileName}`, e);
- // Ignore errors and use the original - we don't want to block the whole
- // download or export for an errant file. TODO: This is not always going
- // to be the correct choice, but instead trying further hack around with
- // the Exif modifications (and all the caveats that come with it), a
- // more principled approach is to put our metadata in a sidecar and
- // never touch the original. We can then and provide additional tools to
- // update the original if the user so wishes from the sidecar.
- return blob.stream();
- }
-};
-
-/**
- * Return a new blob with the "DateTimeOriginal" Exif tag set to the given
- * {@link date}.
- *
- * @param jpegBlob A {@link Blob} containing JPEG data.
- *
- * @param date A {@link Date} to use as the value for the Exif
- * "DateTimeOriginal" tag.
- *
- * @returns A new blob derived from {@link jpegBlob} but with the updated date.
- */
-const setJPEGExifDateTimeOriginal = async (jpegBlob: Blob, date: Date) => {
- let dataURL = await blobToDataURL(jpegBlob);
- // Since we pass a Blob without an associated type, we get back a generic
- // data URL of the form "data:application/octet-stream;base64,...".
- //
- // Modify it to have a `image/jpeg` MIME type.
- dataURL = "data:image/jpeg;base64" + dataURL.slice(dataURL.indexOf(","));
-
- const exifObj = piexif.load(dataURL);
- if (!exifObj.Exif) exifObj.Exif = {};
- exifObj.Exif[piexif.ExifIFD.DateTimeOriginal] =
- convertToExifDateFormat(date);
- const exifBytes = piexif.dump(exifObj);
- const exifInsertedFile = piexif.insert(exifBytes, dataURL);
-
- return dataURLToBlob(exifInsertedFile);
-};
-
-/**
- * Convert a blob to a `data:` URL.
- */
-const blobToDataURL = (blob: Blob) =>
- new Promise((resolve) => {
- const reader = new FileReader();
- // We need to cast to a string here. This should be safe since MDN says:
- //
- // > the result attribute contains the data as a data: URL representing
- // > the file's data as a base64 encoded string.
- // >
- // > https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL
- reader.onload = () => resolve(reader.result as string);
- reader.readAsDataURL(blob);
- });
-
-/**
- * Convert a `data:` URL to a blob.
- *
- * Requires `connect-src data:` in the CSP (since it internally uses `fetch` to
- * perform the conversion).
- */
-const dataURLToBlob = (dataURI: string) =>
- fetch(dataURI).then((res) => res.blob());
-
-/**
- * Convert the given {@link Date} to a format that is expected by Exif for the
- * DateTimeOriginal tag.
- *
- * See: [Note: Exif dates]
- *
- * ---
- *
- * TODO: This functionality is deprecated. The library we use here is
- * unmaintained and there are no comprehensive other JS libs.
- *
- * Instead of doing this in this selective way, we should provide a CLI tool
- * with better format support and more comprehensive handling of Exif and other
- * metadata fields (like captions) that can be used by the user to modify their
- * original from the Ente sidecar if they so wish.
- */
-const convertToExifDateFormat = (date: Date) => {
- const YYYY = zeroPad(date.getFullYear(), 4);
- // JavaScript getMonth is zero-indexed, we want one-indexed.
- const MM = zeroPad(date.getMonth() + 1, 2);
- // JavaScript getDate is NOT zero-indexed, it is already one-indexed.
- const DD = zeroPad(date.getDate(), 2);
- const HH = zeroPad(date.getHours(), 2);
- const mm = zeroPad(date.getMinutes(), 2);
- const ss = zeroPad(date.getSeconds(), 2);
-
- return `${YYYY}:${MM}:${DD} ${HH}:${mm}:${ss}`;
-};
-
-/** Zero pad the given number to {@link d} digits. */
-const zeroPad = (n: number, d: number) => n.toString().padStart(d, "0");
diff --git a/web/packages/new/photos/services/feature-flags.ts b/web/packages/new/photos/services/feature-flags.ts
deleted file mode 100644
index d694522228..0000000000
--- a/web/packages/new/photos/services/feature-flags.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { authenticatedRequestHeaders, ensureOk } from "@/base/http";
-import { localUser } from "@/base/local-user";
-import log from "@/base/log";
-import { apiURL } from "@/base/origins";
-import { nullToUndefined } from "@/utils/transform";
-import { z } from "zod";
-
-let _fetchTimeout: ReturnType | undefined;
-let _haveFetched = false;
-
-/**
- * Fetch feature flags (potentially user specific) from remote and save them in
- * local storage for subsequent lookup.
- *
- * It fetches only once per session, and so is safe to call as arbitrarily many
- * times. Remember to call {@link clearFeatureFlagSessionState} on logout to
- * clear any in memory state so that these can be fetched again on the
- * subsequent login.
- *
- * [Note: Feature Flags]
- *
- * The workflow with feature flags is:
- *
- * 1. On app start feature flags are fetched once and saved in local storage. If
- * this fetch fails, we try again periodically (on every "sync") until
- * success.
- *
- * 2. Attempts to access any individual feature flage (e.g.
- * {@link isInternalUser}) returns the corresponding value from local storage
- * (substituting a default if needed).
- *
- * 3. However, if perchance the fetch-on-app-start hasn't completed yet (or had
- * failed), then a new fetch is tried. If even this fetch fails, we return
- * the default. Otherwise the now fetched result is saved to local storage
- * and the corresponding value returned.
- */
-export const triggerFeatureFlagsFetchIfNeeded = () => {
- if (_haveFetched) return;
- if (_fetchTimeout) return;
- // Not critical, so fetch these after some delay.
- _fetchTimeout = setTimeout(() => {
- _fetchTimeout = undefined;
- void fetchAndSaveFeatureFlags().then(() => {
- _haveFetched = true;
- });
- }, 2000);
-};
-
-export const clearFeatureFlagSessionState = () => {
- if (_fetchTimeout) {
- clearTimeout(_fetchTimeout);
- _fetchTimeout = undefined;
- }
- _haveFetched = false;
-};
-
-/**
- * Fetch feature flags (potentially user specific) from remote and save them in
- * local storage for subsequent lookup.
- */
-const fetchAndSaveFeatureFlags = () =>
- fetchFeatureFlags()
- .then((res) => res.text())
- .then(saveFlagJSONString);
-
-const fetchFeatureFlags = async () => {
- const res = await fetch(await apiURL("/remote-store/feature-flags"), {
- headers: await authenticatedRequestHeaders(),
- });
- ensureOk(res);
- return res;
-};
-
-const saveFlagJSONString = (s: string) =>
- localStorage.setItem("remoteFeatureFlags", s);
-
-const remoteFeatureFlags = () => {
- const s = localStorage.getItem("remoteFeatureFlags");
- if (!s) return undefined;
- return FeatureFlags.parse(JSON.parse(s));
-};
-
-const FeatureFlags = z.object({
- internalUser: z.boolean().nullish().transform(nullToUndefined),
- betaUser: z.boolean().nullish().transform(nullToUndefined),
-});
-
-type FeatureFlags = z.infer;
-
-const remoteFeatureFlagsFetchingIfNeeded = async () => {
- let ff = remoteFeatureFlags();
- if (!ff) {
- try {
- await fetchAndSaveFeatureFlags();
- } catch (e) {
- log.warn("Ignoring error when fetching feature flags", e);
- }
- ff = remoteFeatureFlags();
- }
- return ff;
-};
-
-/**
- * Return `true` if the current user is marked as an "internal" user.
- *
- * 1. Emails that end in `@ente.io` are considered as internal users.
- * 2. If the "internalUser" remote feature flag is set, the user is internal.
- * 3. Otherwise false.
- *
- * See also: [Note: Feature Flags].
- */
-export const isInternalUser = async () => {
- const user = localUser();
- if (user?.email.endsWith("@ente.io")) return true;
-
- const flags = await remoteFeatureFlagsFetchingIfNeeded();
- return flags?.internalUser ?? false;
-};
-
-/**
- * Return `true` if the current user is marked as a "beta" user.
- *
- * See also: [Note: Feature Flags].
- */
-export const isBetaUser = async () => {
- const flags = await remoteFeatureFlagsFetchingIfNeeded();
- return flags?.betaUser ?? false;
-};
diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts
index 5e091f9f6a..73280c0f70 100644
--- a/web/packages/new/photos/services/ml/index.ts
+++ b/web/packages/new/photos/services/ml/index.ts
@@ -41,9 +41,9 @@ import type { CLIPMatches } from "./worker-types";
/**
* Internal state of the ML subsystem.
*
- * This are essentially cached values used by the functions of this module.
+ * These are essentially cached values used by the functions of this module.
*
- * This should be cleared on logout.
+ * They will be cleared on logout.
*/
class MLState {
/**
diff --git a/web/packages/new/photos/services/remote-store.ts b/web/packages/new/photos/services/remote-store.ts
index 875f0dd3bc..2981e87bdd 100644
--- a/web/packages/new/photos/services/remote-store.ts
+++ b/web/packages/new/photos/services/remote-store.ts
@@ -2,6 +2,38 @@ import { authenticatedRequestHeaders, ensureOk } from "@/base/http";
import { apiURL } from "@/base/origins";
import { z } from "zod";
+/**
+ * [Note: Remote store]
+ *
+ * The remote store provides a unified interface for persisting varied "remote
+ * flags":
+ *
+ * - User preferences like "mapEnabled"
+ *
+ * - Feature flags like "isInternalUser"
+ *
+ * There are two APIs to get the current state from remote:
+ *
+ * 1. GET /remote-store/feature-flags fetches the combined state (nb: even
+ * though the name of the endpoint has the word feature-flags, it also
+ * includes user preferences).
+ *
+ * 2. GET /remote-store fetches individual values.
+ *
+ * Usually 1 is what we use, since it gets us everything in a single go, and
+ * which we can also easily cache in local storage by saving the entire response
+ * JSON blob.
+ *
+ * There is a single API (/remote-store/update) to update the state on remote.
+ */
+export const fetchFeatureFlags = async () => {
+ const res = await fetch(await apiURL("/remote-store/feature-flags"), {
+ headers: await authenticatedRequestHeaders(),
+ });
+ ensureOk(res);
+ return res;
+};
+
/**
* Fetch the value for the given {@link key} from remote store.
*
diff --git a/web/packages/new/photos/services/settings.ts b/web/packages/new/photos/services/settings.ts
new file mode 100644
index 0000000000..d0503bda98
--- /dev/null
+++ b/web/packages/new/photos/services/settings.ts
@@ -0,0 +1,153 @@
+/**
+ * @file Storage (in-memory, local, remote) and update of various settings.
+ */
+
+import { localUser } from "@/base/local-user";
+import log from "@/base/log";
+import { nullToUndefined } from "@/utils/transform";
+import { z } from "zod";
+import { fetchFeatureFlags } from "./remote-store";
+
+/**
+ * Internal in-memory state shared by the functions in this module.
+ *
+ * This entire object will be reset on logout.
+ */
+class SettingsState {
+ /**
+ * An arbitrary token to identify the current login.
+ *
+ * It is used to discard stale completions.
+ */
+ id: number;
+
+ constructor() {
+ this.id = Math.random();
+ }
+
+ /**
+ * True if we have performed a fetch for the logged in user since the app
+ * started.
+ */
+ haveSynced = false;
+
+ /**
+ * In-memory flag that tracks if the current user is an internal user.
+ *
+ * See: [Note: Remote flag lifecycle].
+ */
+ isInternalUser = false;
+
+ /**
+ * In-memory flag that tracks if maps are enabled.
+ *
+ * See: [Note: Remote flag lifecycle].
+ */
+ isMapEnabled = false;
+}
+
+/** State shared by the functions in this module. See {@link SettingsState}. */
+let _state = new SettingsState();
+
+/**
+ * Fetch remote flags (feature flags and other user specific preferences) from
+ * remote and save them in local storage for subsequent lookup.
+ *
+ * It fetches only once per app lifetime, and so is safe to call as arbitrarily
+ * many times. Remember to call {@link clearFeatureFlagSessionState} on logout
+ * to clear any in memory state so that these can be fetched again on the
+ * subsequent login.
+ *
+ * The local cache will also be updated if an individual flag is changed.
+ *
+ * [Note: Remote flag lifecycle]
+ *
+ * At a high level, this is how the app manages remote flags:
+ *
+ * 1. On app start, the initial are read from local storage in
+ * {@link initSettings}.
+ *
+ * 2. On app start, as part of the normal sync with remote, remote flags are
+ * fetched once and saved in local storage, and the in-memory state updated
+ * to reflect the latest values ({@link triggerSettingsSyncIfNeeded}). If
+ * this fetch fails, we try again periodically (on every sync with remote)
+ * until success.
+ *
+ * 3. Some operations like opening the preferences panel or updating a value
+ * also cause an unconditional fetch and update ({@link syncSettings}).
+ *
+ * 4. The individual getter functions for the flags (e.g.
+ * {@link isInternalUser}) return the in-memory values, and so are suitable
+ * for frequent use during UI rendering.
+ *
+ * 5. Everything gets reset to the default state on {@link logoutSettings}.
+ */
+export const triggerSettingsSyncIfNeeded = () => {
+ if (!_state.haveSynced) void syncSettings();
+};
+
+/**
+ * Read in the locally persisted settings into memory, but otherwise do not
+ * initiate any network requests to fetch the latest values.
+ *
+ * This assumes that the user is already logged in.
+ */
+export const initSettings = () => {
+ readInMemoryFlagsFromLocalStorage();
+};
+
+export const logoutSettings = () => {
+ _state = new SettingsState();
+};
+
+/**
+ * Fetch remote flags from remote and save them in local storage for subsequent
+ * lookup. Then use the results to update our in memory state if needed.
+ */
+export const syncSettings = async () => {
+ const id = _state.id;
+ const jsonString = await fetchFeatureFlags().then((res) => res.text());
+ if (_state.id != id) {
+ log.info("Discarding stale settings sync not for the current login");
+ return;
+ }
+ saveRemoteFeatureFlagsJSONString(jsonString);
+ readInMemoryFlagsFromLocalStorage();
+ _state.haveSynced = true;
+};
+
+const saveRemoteFeatureFlagsJSONString = (s: string) =>
+ localStorage.setItem("remoteFeatureFlags", s);
+
+const savedRemoteFeatureFlags = () => {
+ const s = localStorage.getItem("remoteFeatureFlags");
+ if (!s) return undefined;
+ return FeatureFlags.parse(JSON.parse(s));
+};
+
+const FeatureFlags = z.object({
+ internalUser: z.boolean().nullish().transform(nullToUndefined),
+ betaUser: z.boolean().nullish().transform(nullToUndefined),
+});
+
+type FeatureFlags = z.infer;
+
+const readInMemoryFlagsFromLocalStorage = () => {
+ const flags = savedRemoteFeatureFlags();
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ _state.isInternalUser = flags?.internalUser || isInternalUserViaEmail();
+};
+
+const isInternalUserViaEmail = () => {
+ const user = localUser();
+ return !!user?.email.endsWith("@ente.io");
+};
+
+/**
+ * Return `true` if the current user is marked as an "internal" user.
+ *
+ * 1. Emails that end in `@ente.io` are considered as internal users.
+ * 2. If the "internalUser" remote feature flag is set, the user is internal.
+ * 3. Otherwise false.
+ */
+export const isInternalUser = () => _state.isInternalUser;
diff --git a/web/packages/new/photos/types/context.ts b/web/packages/new/photos/types/context.ts
index 1d156e1cce..40c679583b 100644
--- a/web/packages/new/photos/types/context.ts
+++ b/web/packages/new/photos/types/context.ts
@@ -10,13 +10,14 @@ import type { SetNotificationAttributes } from "./notification";
*/
export type AppContextT = AccountsContextT & {
/**
- * Show the global activity indicator (a green bar at the top of the page).
+ * Show the global activity indicator (a loading bar at the top of the
+ * page).
*/
- startLoading: () => void;
+ showLoadingBar: () => void;
/**
- * Hide the global activity indicator.
+ * Hide the global activity indicator bar.
*/
- finishLoading: () => void;
+ hideLoadingBar: () => void;
/**
* Show a generic error dialog, and log the given error.
*/
diff --git a/web/packages/new/photos/types/piexifjs.d.ts b/web/packages/new/photos/types/piexifjs.d.ts
deleted file mode 100644
index 211ee9754e..0000000000
--- a/web/packages/new/photos/types/piexifjs.d.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Types for [piexifjs](https://github.com/hMatoba/piexifjs).
- *
- * Non exhaustive, only the function we need.
- */
-declare module "piexifjs" {
- interface ExifObj {
- Exif?: Record;
- }
-
- interface Piexifjs {
- /**
- * Get exif data as object.
- *
- * @param jpegData a string that starts with "data:image/jpeg;base64,"
- * (a data URL), "\xff\xd8", or "Exif".
- */
- load: (jpegData: string) => ExifObj;
- /**
- * Get exif as string to insert into JPEG.
- *
- * @param exifObj An object obtained using {@link load}.
- */
- dump: (exifObj: ExifObj) => string;
- /**
- * Insert exif into JPEG.
- *
- * If {@link jpegData} is a data URL, returns the modified JPEG as a
- * data URL. Else if {@link jpegData} is binary as string, returns JPEG
- * as binary as string.
- */
- insert: (exifStr: string, jpegData: string) => string;
- /**
- * Keys for the tags in {@link ExifObj}.
- */
- ExifIFD: {
- DateTimeOriginal: number;
- };
- }
- const piexifjs: Piexifjs;
- export default piexifjs;
-}
diff --git a/web/yarn.lock b/web/yarn.lock
index a3485ab6ec..ab0c8a1aa9 100644
--- a/web/yarn.lock
+++ b/web/yarn.lock
@@ -3602,11 +3602,6 @@ picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
-piexifjs@^1.0.6:
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/piexifjs/-/piexifjs-1.0.6.tgz#883811d73f447218d0d06e9ed7866d04533e59e0"
- integrity sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==
-
pngjs@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"
@@ -3743,10 +3738,10 @@ react-is@^18.3.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
-react-otp-input@^2.3.1:
- version "2.4.0"
- resolved "https://registry.yarnpkg.com/react-otp-input/-/react-otp-input-2.4.0.tgz#0f0a3de1d8c8d564e2e4fbe5d6b7b56e29e3a6e6"
- integrity sha512-AIgl7u4sS9BTNCxX1xlaS5fPWay/Zml8Ho5LszXZKXrH1C/TiFsTQGmtl13UecQYO3mSF3HUzG2rrDf0sjEFmg==
+react-otp-input@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/react-otp-input/-/react-otp-input-3.1.1.tgz#910169629812c40a614e6c175cc2c5f36102bb61"
+ integrity sha512-bjPavgJ0/Zmf/AYi4onj8FbH93IjeD+e8pWwxIJreDEWsU1ILR5fs8jEJmMGWSBe/yyvPP6X/W6Mk9UkOCkTPw==
react-refresh@^0.14.2:
version "0.14.2"
@@ -3768,7 +3763,7 @@ react-select@^5.8.0:
react-transition-group "^4.3.0"
use-isomorphic-layout-effect "^1.1.2"
-react-top-loading-bar@^2.0.1:
+react-top-loading-bar@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/react-top-loading-bar/-/react-top-loading-bar-2.3.1.tgz#d727eb6aaa412eae52a990e5de9f33e9136ac714"
integrity sha512-rQk2Nm+TOBrM1C4E3e6KwT65iXyRSgBHjCkr2FNja1S51WaPulRA5nKj/xazuQ3x89wDDdGsrqkqy0RBIfd0xg==