Redesign App Store

Replace the legacy App Store presentation with a features/app-store feature
built on the refreshed design system: a Discover page with an updates shelf
and optional editorial sections (spotlights, curated collections, category
features) from the apps.umbrel.com v3 API, category pages with featured apps,
blurbs and date-aware sorting, and a recomposed app page with a collapsing
hero, info band, release timeline, and related apps.
This commit is contained in:
Mayank Chhabra
2026-09-02 21:55:44 +07:00
committed by Luke Childs
parent ca26da09c8
commit b2a5ba5f98
161 changed files with 7082 additions and 2327 deletions
+4
View File
@@ -39,6 +39,10 @@ tor/*
app-data/*
data/
# Source modules for the app store, not runtime service data
!packages/ui/src/features/app-store/data/
!packages/ui/src/features/app-store/data/**
# Git worktrees (see AGENTS.md). Git creates this on demand and does not
# ignore nested worktrees on its own, so it only needs ignoring, not tracking.
# Anchored so a worktrees/ directory elsewhere in the tree isn't caught.
+16
View File
@@ -53,4 +53,20 @@ export default [
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['src/modules/app-store/**/*.{js,jsx,ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/features/app-store', '@/features/app-store/**'],
message: 'Legacy App Store UI must depend on shared hooks/domain code, not the redesigned feature.',
},
],
},
],
},
},
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, мрежови настройки и още",
"app-not-found": "Приложението не е намерено: {{app}}",
"app-only-over-tor": "{{app}} може да се използва само през Tor. Моля, отвори своя Umbrel в Tor браузър чрез URL адреса си за отдалечен достъп (Settings > Advanced settings > Remote Tor access), за да отвориш това приложение.",
"app-page.section.about": "Относно",
"app-page.section.about": "Относно това приложение",
"app-page.section.credentials.title": "Стандартни данни за вход",
"app-page.section.dependencies.n-alternatives": "Виж {{count}} алтернативи",
"app-page.section.info.compatibility": "Съвместимост",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Публичен",
"app-page.section.info.submitted-by": "Изпратено от",
"app-page.section.info.support": "Получи помощ",
"app-page.section.info.title": "Информация",
"app-page.section.info.version": "Версия",
"app-page.section.recommendations.title": "Може да ти хареса също",
"app-page.section.release-notes.title": "Какво е новото",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Приложението не можа да бъде споделено",
"app-share-users.title": "Сподели „{{name}}“ с потребители",
"app-share-users.unshare-failed": "Споделянето на приложението не можа да бъде спряно",
"app-store.all-category-apps": "Всички приложения в категория „{{category}}“",
"app-store.app-id-conflict": "Идентификаторът на това приложение е в конфликт с друг App Store. Премахни един от конфликтните App Store, за да управляваш приложението.",
"app-store.ask-owner-to-install": "Моля, помоли собственика да инсталира това приложение",
"app-store.ask-owner-to-update": "Помоли собственика да обнови това приложение",
"app-store.browse-category-apps": "Разгледай приложенията в {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Всички приложения",
@@ -77,15 +79,33 @@
"app-store.category.media": "Медия",
"app-store.category.networking": "Мрежа",
"app-store.category.social": "Социални",
"app-store.dependency-metadata-unavailable": "Метаданните не са налични",
"app-store.description": "Настройки за обновяване на приложенията",
"app-store.discover.temporarily-unavailable-description": "Разгледай категориите по-горе или използвай търсене, за да намериш приложения",
"app-store.discover.temporarily-unavailable-title": "Препоръчаното съдържание временно не е налично",
"app-store.discover-unavailable": "Откриването не е налично в момента",
"app-store.featured-apps": "Препоръчани приложения",
"app-store.info.support": "Поддръжка",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Няма бележки към изданието за тази версия.",
"app-store.search-apps": "Търси приложения",
"app-store.search.clear": "Изчисти търсенето",
"app-store.search.no-results": "Няма резултати",
"app-store.search.no-results-description": "Опитай друго търсене или разгледай категориите по-горе.",
"app-store.search.result-count_one": "{{count}} резултат",
"app-store.search.result-count_other": "{{count}} резултата",
"app-store.search.results-for": "Резултати за",
"app-store.section.all-apps": "Всички приложения",
"app-store.sort.label": "Сортиране",
"app-store.sort.name": "По азбучен ред",
"app-store.sort.newest": "Наскоро добавени",
"app-store.sort.recently-updated": "Наскоро обновени",
"app-store.status.in-progress": "В процес",
"app-store.status.incompatible": "Несъвместимо",
"app-store.status.update-available": "Налично обновяване",
"app-store.title": "App Store",
"app-store.updates": "Обновления",
"app-store.updates-shelf.names-and-more": "{{names}} и още",
"app-store.updates-shelf.os-update-required-count_one": "За {{count}} обновяване е необходима по-нова версия на umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "За {{count}} обновявания е необходима по-нова версия на umbrelOS",
"app-updates.less": "по-малко",
"app-updates.more": "повече",
"app-updates.no-updates": "Всички приложения са актуални!",
@@ -100,7 +120,6 @@
"app.installing": "Инсталиране",
"app.offline": "Не работи",
"app.open": "Отвори",
"app.optimized-for-umbrel-home": "Оптимизирано за Umbrel Home",
"app.os-update-required.confirm": "Провери за обновяване на umbrelOS",
"app.os-update-required.description": "{{appName}} изисква umbrelOS {{version}} или по-нова версия",
"app.os-update-required.title": "Обнови umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Неуспешно рестартиране на приложението",
"app.toast.start-failed": "Неуспешно стартиране на приложението",
"app.toast.stop-failed": "Неуспешно спиране на приложението",
"app.toast.update-failed": "Приложението не можа да се обнови",
"app.uninstall.confirm.description": "Всички данни, свързани с {{app}}, ще бъдат изтрити безвъзвратно. Това действие не може да бъде отменено.",
"app.uninstall.confirm.submit": "Деинсталирай",
"app.uninstall.confirm.title": "Да деинсталирам ли {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Настрой на",
"https-access-view-instructions": "Как да използваш HTTPS",
"install-first.install-app": "Инсталирай {{app}}",
"install-first.title": "{{app}} изисква тези приложения",
"install-first.title_one": "{{app}} изисква това приложение",
"install-first.title_other": "{{app}} изисква тези приложения",
"install-your-first-app": "Инсталирай първото си приложение",
"language": "Език",
"language-description": "Предпочитан език на umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, nastavení sítě a další",
"app-not-found": "Aplikace nenalezena: {{app}}",
"app-only-over-tor": "{{app}} lze používat jen přes Tor. Otevři svůj Umbrel v prohlížeči Tor na své URL pro vzdálený přístup (Nastavení > Pokročilá nastavení > Vzdálený přístup přes Tor), abys tuto aplikaci otevřel.",
"app-page.section.about": "O aplikaci",
"app-page.section.about": "O této aplikaci",
"app-page.section.credentials.title": "Výchozí přihlašovací údaje",
"app-page.section.dependencies.n-alternatives": "Zobrazit {{count}} alternativ",
"app-page.section.info.compatibility": "Kompatibilita",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Veřejný",
"app-page.section.info.submitted-by": "Přidal",
"app-page.section.info.support": "Získat podporu",
"app-page.section.info.title": "Informace",
"app-page.section.info.version": "Verze",
"app-page.section.recommendations.title": "Mohlo by se ti líbit",
"app-page.section.release-notes.title": "Co je nového",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Aplikaci se nepodařilo sdílet",
"app-share-users.title": "Sdílet „{{name}}“ s uživateli",
"app-share-users.unshare-failed": "Sdílení aplikace se nepodařilo zrušit",
"app-store.all-category-apps": "Všechny aplikace v kategorii {{category}}",
"app-store.app-id-conflict": "Toto ID aplikace koliduje s jiným obchodem s aplikacemi. Abys mohl tuto aplikaci spravovat, odeber jeden z kolidujících obchodů.",
"app-store.ask-owner-to-install": "Požádej vlastníka, aby tuto aplikaci nainstaloval",
"app-store.ask-owner-to-update": "Požádej vlastníka, aby tuto aplikaci aktualizoval",
"app-store.browse-category-apps": "Procházet aplikace v kategorii {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Všechny aplikace",
@@ -77,15 +79,33 @@
"app-store.category.media": "Média",
"app-store.category.networking": "Sítě",
"app-store.category.social": "Sociální sítě",
"app-store.dependency-metadata-unavailable": "Metadata nejsou k dispozici",
"app-store.description": "Nastavení aktualizací aplikací",
"app-store.discover.temporarily-unavailable-description": "Procházej kategorie výše nebo hledej a najdi aplikace",
"app-store.discover.temporarily-unavailable-title": "Doporučený obsah je dočasně nedostupný",
"app-store.discover-unavailable": "Objevování teď není k dispozici",
"app-store.featured-apps": "Doporučené aplikace",
"app-store.info.support": "Podpora",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Pro tuto verzi nejsou žádné poznámky k vydání.",
"app-store.search-apps": "Hledat aplikace",
"app-store.search.clear": "Vymazat hledání",
"app-store.search.no-results": "Žádné výsledky",
"app-store.search.no-results-description": "Zkus hledat něco jiného nebo projdi kategorie výše.",
"app-store.search.result-count_one": "{{count}} výsledek",
"app-store.search.result-count_other": "Počet výsledků: {{count}}",
"app-store.search.results-for": "Výsledky pro",
"app-store.section.all-apps": "Všechny aplikace",
"app-store.sort.label": "Řazení",
"app-store.sort.name": "Podle abecedy",
"app-store.sort.newest": "Nově přidané",
"app-store.sort.recently-updated": "Nedávno aktualizované",
"app-store.status.in-progress": "Probíhá",
"app-store.status.incompatible": "Nekompatibilní",
"app-store.status.update-available": "Dostupná aktualizace",
"app-store.title": "App Store",
"app-store.updates": "Aktualizace",
"app-store.updates-shelf.names-and-more": "{{names}} a další",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} aktualizace vyžaduje novější verzi umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "Novější verzi umbrelOS vyžaduje tento počet aktualizací: {{count}}",
"app-updates.less": "méně",
"app-updates.more": "více",
"app-updates.no-updates": "Všechny aplikace jsou aktuální!",
@@ -100,7 +120,6 @@
"app.installing": "Instaluje se",
"app.offline": "Neběží",
"app.open": "Otevřít",
"app.optimized-for-umbrel-home": "Optimalizováno pro Umbrel Home",
"app.os-update-required.confirm": "Zkontrolovat aktualizaci umbrelOS",
"app.os-update-required.description": "{{appName}} vyžaduje umbrelOS {{version}} nebo novější",
"app.os-update-required.title": "Aktualizovat umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Nepodařilo se restartovat aplikaci",
"app.toast.start-failed": "Nepodařilo se spustit aplikaci",
"app.toast.stop-failed": "Nepodařilo se zastavit aplikaci",
"app.toast.update-failed": "Aplikaci se nepodařilo aktualizovat",
"app.uninstall.confirm.description": "Všechna data spojená s aplikací {{app}} budou trvale smazána. Tuto akci nelze vrátit zpět.",
"app.uninstall.confirm.submit": "Odinstalovat",
"app.uninstall.confirm.title": "Odinstalovat {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Nastavit na",
"https-access-view-instructions": "Jak používat HTTPS",
"install-first.install-app": "Nainstalovat {{app}}",
"install-first.title": "{{app}} vyžaduje tyto aplikace",
"install-first.title_one": "{{app}} vyžaduje tuto aplikaci",
"install-first.title_other": "{{app}} vyžaduje tyto aplikace",
"install-your-first-app": "Nainstaluj svou první aplikaci",
"language": "Jazyk",
"language-description": "Preferovaný jazyk umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, netværksindstillinger og meget mere",
"app-not-found": "App ikke fundet: {{app}}",
"app-only-over-tor": "{{app}} kan kun bruges over Tor. Åbn din Umbrel i en Tor-browser på din fjernadgangs-URL (Indstillinger > Avancerede indstillinger > Fjernadgang via Tor) for at åbne denne app.",
"app-page.section.about": "Om",
"app-page.section.about": "Om denne app",
"app-page.section.credentials.title": "Standard-loginoplysninger",
"app-page.section.dependencies.n-alternatives": "Se {{count}} alternativer",
"app-page.section.info.compatibility": "Kompatibilitet",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Offentlig",
"app-page.section.info.submitted-by": "Indsendt af",
"app-page.section.info.support": "Få hjælp",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Version",
"app-page.section.recommendations.title": "Det her kan du måske også lide",
"app-page.section.release-notes.title": "Hvad er nyt",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Kunne ikke dele appen",
"app-share-users.title": "Del “{{name}}” med brugere",
"app-share-users.unshare-failed": "Kunne ikke ophæve deling af appen",
"app-store.all-category-apps": "Alle apps i kategorien {{category}}",
"app-store.app-id-conflict": "Dette app-id er i konflikt med en anden App Store. Fjern en af de App Stores, der er i konflikt, for at administrere denne app.",
"app-store.ask-owner-to-install": "Bed ejeren om at installere denne app",
"app-store.ask-owner-to-update": "Bed ejeren om at opdatere denne app",
"app-store.browse-category-apps": "Gennemse apps i kategorien {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Alle apps",
@@ -77,15 +79,33 @@
"app-store.category.media": "Medier",
"app-store.category.networking": "Netværk",
"app-store.category.social": "Social",
"app-store.dependency-metadata-unavailable": "Metadata er ikke tilgængelige",
"app-store.description": "Dine appopdateringsindstillinger",
"app-store.discover.temporarily-unavailable-description": "Gennemse kategorierne ovenfor, eller søg for at finde apps",
"app-store.discover.temporarily-unavailable-title": "Fremhævet indhold er midlertidigt utilgængeligt",
"app-store.discover-unavailable": "Discover er ikke tilgængelig lige nu",
"app-store.featured-apps": "Udvalgte apps",
"app-store.info.support": "Support",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Ingen udgivelsesnoter til denne version.",
"app-store.search-apps": "Søg efter apps",
"app-store.search.clear": "Ryd søgning",
"app-store.search.no-results": "Ingen resultater",
"app-store.search.no-results-description": "Prøv en anden søgning, eller gennemse kategorierne ovenfor.",
"app-store.search.result-count_one": "{{count}} resultat",
"app-store.search.result-count_other": "{{count}} resultater",
"app-store.search.results-for": "Resultater for",
"app-store.section.all-apps": "Alle apps",
"app-store.sort.label": "Sortér",
"app-store.sort.name": "Alfabetisk",
"app-store.sort.newest": "Senest tilføjet",
"app-store.sort.recently-updated": "Senest opdateret",
"app-store.status.in-progress": "I gang",
"app-store.status.incompatible": "Ikke kompatibel",
"app-store.status.update-available": "Opdatering tilgængelig",
"app-store.title": "App Store",
"app-store.updates": "Opdateringer",
"app-store.updates-shelf.names-and-more": "{{names}} og flere",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} opdatering kræver en nyere version af umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} opdateringer kræver en nyere version af umbrelOS",
"app-updates.less": "mindre",
"app-updates.more": "mere",
"app-updates.no-updates": "Alle apps er opdaterede!",
@@ -100,7 +120,6 @@
"app.installing": "Installerer",
"app.offline": "Kører ikke",
"app.open": "Åbn",
"app.optimized-for-umbrel-home": "Optimeret til Umbrel Home",
"app.os-update-required.confirm": "Søg efter umbrelOS-opdatering",
"app.os-update-required.description": "{{appName}} kræver umbrelOS {{version}} eller nyere",
"app.os-update-required.title": "Opdater umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Kunne ikke genstarte appen",
"app.toast.start-failed": "Kunne ikke starte appen",
"app.toast.stop-failed": "Kunne ikke stoppe appen",
"app.toast.update-failed": "Appen kunne ikke opdateres",
"app.uninstall.confirm.description": "Alle data, der er knyttet til {{app}}, slettes permanent. Denne handling kan ikke fortrydes.",
"app.uninstall.confirm.submit": "Afinstaller",
"app.uninstall.confirm.title": "Afinstaller {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Sæt op på",
"https-access-view-instructions": "Sådan bruger du HTTPS",
"install-first.install-app": "Installer {{app}}",
"install-first.title": "{{app}} kræver disse apps",
"install-first.title_one": "{{app}} kræver denne app",
"install-first.title_other": "{{app}} kræver disse apps",
"install-your-first-app": "Installer din første app",
"language": "Sprog",
"language-description": "Dit foretrukne umbrelOS-sprog",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta-Programm, Netzwerkeinstellungen und mehr",
"app-not-found": "App nicht gefunden: {{app}}",
"app-only-over-tor": "{{app}} kann nur über Tor verwendet werden. Bitte rufe dein Umbrel in einem Tor-Browser über deine Remote‑Zugriffs‑URL (Einstellungen > Erweiterte Einstellungen > Remote Tor‑Zugriff) auf, um diese App zu öffnen.",
"app-page.section.about": "Über",
"app-page.section.about": "Über diese App",
"app-page.section.credentials.title": "Standardanmeldeinformationen",
"app-page.section.dependencies.n-alternatives": "{{count}} Alternativen ansehen",
"app-page.section.info.compatibility": "Kompatibilität",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Öffentlich",
"app-page.section.info.submitted-by": "Eingereicht von",
"app-page.section.info.support": "Support erhalten",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Version",
"app-page.section.recommendations.title": "Das könnte dir auch gefallen",
"app-page.section.release-notes.title": "Neuigkeiten",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "App konnte nicht geteilt werden",
"app-share-users.title": "„{{name}}“ mit Benutzern teilen",
"app-share-users.unshare-failed": "Freigabe der App konnte nicht aufgehoben werden",
"app-store.all-category-apps": "Alle Apps in {{category}}",
"app-store.app-id-conflict": "Diese App-ID verursacht einen Konflikt mit einem anderen App Store. Entferne einen der beiden App Stores, um diese App zu verwalten.",
"app-store.ask-owner-to-install": "Bitte den Eigentümer, diese App zu installieren",
"app-store.ask-owner-to-update": "Bitte den Besitzer, diese App zu aktualisieren",
"app-store.browse-category-apps": "{{category}} Apps durchsuchen",
"app-store.category.ai": "KI",
"app-store.category.all": "Alle Apps",
@@ -77,15 +79,33 @@
"app-store.category.media": "Medien",
"app-store.category.networking": "Netzwerk",
"app-store.category.social": "Sozial",
"app-store.dependency-metadata-unavailable": "Metadaten nicht verfügbar",
"app-store.description": "Deine App-Aktualisierungseinstellungen",
"app-store.discover.temporarily-unavailable-description": "Durchsuche die Kategorien oben oder verwende die Suche, um Apps zu finden",
"app-store.discover.temporarily-unavailable-title": "Ausgewählte Inhalte vorübergehend nicht verfügbar",
"app-store.discover-unavailable": "Entdecken ist derzeit nicht verfügbar",
"app-store.featured-apps": "Empfohlene Apps",
"app-store.info.support": "Support",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Für diese Version gibt es keine Versionshinweise.",
"app-store.search-apps": "Apps suchen",
"app-store.search.clear": "Suche löschen",
"app-store.search.no-results": "Keine Ergebnisse",
"app-store.search.no-results-description": "Versuche es mit einer anderen Suche oder durchsuche die Kategorien oben.",
"app-store.search.result-count_one": "{{count}} Ergebnis",
"app-store.search.result-count_other": "{{count}} Ergebnisse",
"app-store.search.results-for": "Ergebnisse für",
"app-store.section.all-apps": "Alle Apps",
"app-store.sort.label": "Sortieren",
"app-store.sort.name": "Alphabetisch",
"app-store.sort.newest": "Neu hinzugefügt",
"app-store.sort.recently-updated": "Kürzlich aktualisiert",
"app-store.status.in-progress": "In Bearbeitung",
"app-store.status.incompatible": "Nicht kompatibel",
"app-store.status.update-available": "Update verfügbar",
"app-store.title": "App Store",
"app-store.updates": "Aktualisierungen",
"app-store.updates-shelf.names-and-more": "{{names}} und mehr",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} Update erfordert eine neuere Version von umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} Updates erfordern eine neuere Version von umbrelOS",
"app-updates.less": "weniger",
"app-updates.more": "mehr",
"app-updates.no-updates": "Alle Apps sind auf dem neuesten Stand!",
@@ -100,7 +120,6 @@
"app.installing": "Installiert",
"app.offline": "Nicht in Betrieb",
"app.open": "Öffnen",
"app.optimized-for-umbrel-home": "Optimiert für Umbrel Home",
"app.os-update-required.confirm": "Prüfe auf umbrelOS-Update",
"app.os-update-required.description": "{{appName}} erfordert umbrelOS {{version}} oder neuer",
"app.os-update-required.title": "umbrelOS aktualisieren",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "App konnte nicht neu gestartet werden",
"app.toast.start-failed": "App konnte nicht gestartet werden",
"app.toast.stop-failed": "App konnte nicht gestoppt werden",
"app.toast.update-failed": "App konnte nicht aktualisiert werden",
"app.uninstall.confirm.description": "Alle mit {{app}} verbundenen Daten werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"app.uninstall.confirm.submit": "Deinstallieren",
"app.uninstall.confirm.title": "{{app}} deinstallieren?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Einrichten auf",
"https-access-view-instructions": "So verwendest du HTTPS",
"install-first.install-app": "Installiere {{app}}",
"install-first.title": "{{app}} benötigt diese Apps",
"install-first.title_one": "{{app}} benötigt diese App",
"install-first.title_other": "{{app}} benötigt diese Apps",
"install-your-first-app": "Installiere deine erste App",
"language": "Sprache",
"language-description": "Deine bevorzugte umbrelOS Sprache",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, ρυθμίσεις δικτύου και άλλα",
"app-not-found": "Η εφαρμογή δεν βρέθηκε: {{app}}",
"app-only-over-tor": "Το {{app}} μπορεί να χρησιμοποιηθεί μόνο μέσω Tor. Άνοιξε το Umbrel σου σε ένα πρόγραμμα περιήγησης Tor από το URL απομακρυσμένης πρόσβασης σου (Ρυθμίσεις > Προηγμένες ρυθμίσεις > Απομακρυσμένη πρόσβαση Tor) για να ανοίξεις αυτή την εφαρμογή.",
"app-page.section.about": "Σχετικά",
"app-page.section.about": "Σχετικά με αυτή την εφαρμογή",
"app-page.section.credentials.title": "Προεπιλεγμένα στοιχεία σύνδεσης",
"app-page.section.dependencies.n-alternatives": "Δες {{count}} εναλλακτικές",
"app-page.section.info.compatibility": "Συμβατότητα",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Δημόσιος",
"app-page.section.info.submitted-by": "Υποβλήθηκε από",
"app-page.section.info.support": "Λήψη υποστήριξης",
"app-page.section.info.title": "Πληροφορίες",
"app-page.section.info.version": "Έκδοση",
"app-page.section.recommendations.title": "Μπορεί επίσης να σου αρέσουν",
"app-page.section.release-notes.title": "Τι νέο υπάρχει",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Δεν ήταν δυνατή η κοινή χρήση της εφαρμογής",
"app-share-users.title": "Κοινή χρήση του “{{name}}” με χρήστες",
"app-share-users.unshare-failed": "Δεν ήταν δυνατή η διακοπή της κοινής χρήσης της εφαρμογής",
"app-store.all-category-apps": "Όλες οι εφαρμογές: {{category}}",
"app-store.app-id-conflict": "Αυτό το ID εφαρμογής έρχεται σε διένεξη με ένα άλλο App Store. Αφαίρεσε ένα από τα App Store που βρίσκονται σε διένεξη, για να διαχειριστείς αυτή την εφαρμογή.",
"app-store.ask-owner-to-install": "Ζήτησε από τον κάτοχο να εγκαταστήσει αυτή την εφαρμογή",
"app-store.ask-owner-to-update": "Ζήτησε από τον κάτοχο να ενημερώσει αυτή την εφαρμογή",
"app-store.browse-category-apps": "Δες εφαρμογές {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Όλες οι εφαρμογές",
@@ -77,15 +79,33 @@
"app-store.category.media": "Πολυμέσα",
"app-store.category.networking": "Δικτύωση",
"app-store.category.social": "Κοινωνικά",
"app-store.dependency-metadata-unavailable": "Τα μεταδεδομένα δεν είναι διαθέσιμα",
"app-store.description": "Οι ρυθμίσεις ενημέρωσης εφαρμογών",
"app-store.discover.temporarily-unavailable-description": "Δες τις παραπάνω κατηγορίες ή κάνε αναζήτηση για να βρεις εφαρμογές",
"app-store.discover.temporarily-unavailable-title": "Το προτεινόμενο περιεχόμενο δεν είναι προσωρινά διαθέσιμο",
"app-store.discover-unavailable": "Η Εξερεύνηση δεν είναι διαθέσιμη αυτήν τη στιγμή",
"app-store.featured-apps": "Επιλεγμένες εφαρμογές",
"app-store.info.support": "Υποστήριξη",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Δεν υπάρχουν σημειώσεις έκδοσης για αυτή την έκδοση.",
"app-store.search-apps": "Αναζήτηση εφαρμογών",
"app-store.search.clear": "Εκκαθάριση αναζήτησης",
"app-store.search.no-results": "Κανένα αποτέλεσμα",
"app-store.search.no-results-description": "Δοκίμασε διαφορετική αναζήτηση ή περιηγήσου στις παραπάνω κατηγορίες.",
"app-store.search.result-count_one": "{{count}} αποτέλεσμα",
"app-store.search.result-count_other": "{{count}} αποτελέσματα",
"app-store.search.results-for": "Αποτελέσματα για",
"app-store.section.all-apps": "Όλες οι εφαρμογές",
"app-store.sort.label": "Ταξινόμηση",
"app-store.sort.name": "Αλφαβητικά",
"app-store.sort.newest": "Νέες προσθήκες",
"app-store.sort.recently-updated": "Πρόσφατα ενημερωμένες",
"app-store.status.in-progress": "Σε εξέλιξη",
"app-store.status.incompatible": "Μη συμβατή",
"app-store.status.update-available": "Διαθέσιμη ενημέρωση",
"app-store.title": "App Store",
"app-store.updates": "Ενημερώσεις",
"app-store.updates-shelf.names-and-more": "{{names}} και άλλα",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} ενημέρωση απαιτεί νεότερη έκδοση του umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} ενημερώσεις απαιτούν νεότερη έκδοση του umbrelOS",
"app-updates.less": "λιγότερα",
"app-updates.more": "περισσότερα",
"app-updates.no-updates": "Όλες οι εφαρμογές είναι ενημερωμένες!",
@@ -100,7 +120,6 @@
"app.installing": "Γίνεται εγκατάσταση",
"app.offline": "Δεν εκτελείται",
"app.open": "Άνοιγμα",
"app.optimized-for-umbrel-home": "Βελτιστοποιημένο για Umbrel Home",
"app.os-update-required.confirm": "Έλεγχος για ενημέρωση umbrelOS",
"app.os-update-required.description": "Το {{appName}} απαιτεί umbrelOS {{version}} ή νεότερη έκδοση",
"app.os-update-required.title": "Ενημέρωση umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Η επανεκκίνηση της εφαρμογής απέτυχε",
"app.toast.start-failed": "Η εκκίνηση της εφαρμογής απέτυχε",
"app.toast.stop-failed": "Ο τερματισμός της εφαρμογής απέτυχε",
"app.toast.update-failed": "Η ενημέρωση της εφαρμογής απέτυχε",
"app.uninstall.confirm.description": "Όλα τα δεδομένα που σχετίζονται με το {{app}} θα διαγραφούν οριστικά. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"app.uninstall.confirm.submit": "Απεγκατάσταση",
"app.uninstall.confirm.title": "Απεγκατάσταση του {{app}};",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Ρύθμιση σε",
"https-access-view-instructions": "Πώς να χρησιμοποιήσεις το HTTPS",
"install-first.install-app": "Εγκατάσταση {{app}}",
"install-first.title": "Το {{app}} απαιτεί αυτές τις εφαρμογές",
"install-first.title_one": "Η εφαρμογή {{app}} απαιτεί αυτή την εφαρμογή",
"install-first.title_other": "Η εφαρμογή {{app}} απαιτεί αυτές τις εφαρμογές",
"install-your-first-app": "Εγκατάστησε την πρώτη σου εφαρμογή",
"language": "Γλώσσα",
"language-description": "Η προτιμώμενη γλώσσα umbrelOS",
+28 -7
View File
@@ -32,18 +32,17 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, network settings, and more",
"app-not-found": "App not found: {{app}}",
"app-only-over-tor": "{{app}} can only be used over Tor. Please access your Umbrel in a Tor browser on your remote access URL (Settings > Advanced settings > Remote Tor access) to open this app.",
"app-page.section.about": "About",
"app-page.section.about": "About this app",
"app-page.section.credentials.title": "Default credentials",
"app-page.section.dependencies.n-alternatives": "See {{count}} alternatives",
"app-page.section.info.compatibility": "Compatibility",
"app-page.section.info.compatibility-compatible": "Compatible",
"app-page.section.info.compatibility-not-compatible": "Not compatible",
"app-page.section.info.developer": "Developer",
"app-page.section.info.source-code": "Source Code",
"app-page.section.info.source-code": "Source code",
"app-page.section.info.source-code.public": "Public",
"app-page.section.info.submitted-by": "Submitted by",
"app-page.section.info.support": "Get support",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Version",
"app-page.section.recommendations.title": "You might also like",
"app-page.section.release-notes.title": "What's new",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Couldn't share app",
"app-share-users.title": "Share “{{name}}” with users",
"app-share-users.unshare-failed": "Couldn't stop sharing app",
"app-store.all-category-apps": "All {{category}} apps",
"app-store.app-id-conflict": "This app ID conflicts with another app store. Remove one of the conflicting stores to manage this app.",
"app-store.ask-owner-to-install": "Please ask the owner to install this app",
"app-store.ask-owner-to-update": "Please ask the owner to update this app",
"app-store.browse-category-apps": "Browse {{category}} apps",
"app-store.category.ai": "AI",
"app-store.category.all": "All apps",
@@ -77,15 +79,33 @@
"app-store.category.media": "Media",
"app-store.category.networking": "Networking",
"app-store.category.social": "Social",
"app-store.dependency-metadata-unavailable": "Metadata unavailable",
"app-store.description": "Your app update settings",
"app-store.discover.temporarily-unavailable-description": "Browse categories above or search to find apps",
"app-store.discover.temporarily-unavailable-title": "Featured content temporarily unavailable",
"app-store.discover-unavailable": "Discover isn't available right now",
"app-store.featured-apps": "Featured apps",
"app-store.info.support": "Support",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "No release notes for this version.",
"app-store.search-apps": "Search apps",
"app-store.search.clear": "Clear search",
"app-store.search.no-results": "No results",
"app-store.search.no-results-description": "Try a different search, or browse the categories above.",
"app-store.search.result-count_one": "{{count}} result",
"app-store.search.result-count_other": "{{count}} results",
"app-store.search.results-for": "Results for",
"app-store.section.all-apps": "All apps",
"app-store.sort.label": "Sort",
"app-store.sort.name": "Alphabetically",
"app-store.sort.newest": "Newly added",
"app-store.sort.recently-updated": "Recently updated",
"app-store.status.in-progress": "In progress",
"app-store.status.incompatible": "Not compatible",
"app-store.status.update-available": "Update available",
"app-store.title": "App Store",
"app-store.updates": "Updates",
"app-store.updates-shelf.names-and-more": "{{names}}, and more",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} update requires a newer version of umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} updates require a newer version of umbrelOS",
"app-updates.less": "less",
"app-updates.more": "more",
"app-updates.no-updates": "All apps are up-to-date!",
@@ -100,7 +120,6 @@
"app.installing": "Installing",
"app.offline": "Not running",
"app.open": "Open",
"app.optimized-for-umbrel-home": "Optimized for Umbrel Home",
"app.os-update-required.confirm": "Check for umbrelOS update",
"app.os-update-required.description": "{{appName}} requires umbrelOS {{version}} or later",
"app.os-update-required.title": "Update umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Failed to restart app",
"app.toast.start-failed": "Failed to start app",
"app.toast.stop-failed": "Failed to stop app",
"app.toast.update-failed": "Failed to update app",
"app.uninstall.confirm.description": "All data associated with {{app}} will be permanently deleted. This action cannot be undone.",
"app.uninstall.confirm.submit": "Uninstall",
"app.uninstall.confirm.title": "Uninstall {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Set up on",
"https-access-view-instructions": "How to use HTTPS",
"install-first.install-app": "Install {{app}}",
"install-first.title": "{{app}} requires these apps",
"install-first.title_one": "{{app}} requires this app",
"install-first.title_other": "{{app}} requires these apps",
"install-your-first-app": "Install your first app",
"language": "Language",
"language-description": "Your preferred umbrelOS language",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, programa Beta de umbrelOS, ajustes de red y más",
"app-not-found": "Aplicación no encontrada: {{app}}",
"app-only-over-tor": "{{app}} solo se puede usar a través de Tor. Por favor, accede a tu Umbrel desde un navegador Tor usando tu URL de acceso remoto (Settings > Advanced settings > Remote Tor access) para abrir esta aplicación.",
"app-page.section.about": "Acerca de",
"app-page.section.about": "Acerca de esta aplicación",
"app-page.section.credentials.title": "Credenciales predeterminadas",
"app-page.section.dependencies.n-alternatives": "Ver {{count}} alternativas",
"app-page.section.info.compatibility": "Compatibilidad",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Público",
"app-page.section.info.submitted-by": "Enviado por",
"app-page.section.info.support": "Obtener soporte",
"app-page.section.info.title": "Información",
"app-page.section.info.version": "Versión",
"app-page.section.recommendations.title": "También te podría gustar",
"app-page.section.release-notes.title": "Novedades",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "No se pudo compartir la app",
"app-share-users.title": "Compartir “{{name}}” con usuarios",
"app-share-users.unshare-failed": "No se pudo dejar de compartir la app",
"app-store.all-category-apps": "Todas las aplicaciones de {{category}}",
"app-store.app-id-conflict": "El ID de esta aplicación entra en conflicto con otra tienda de aplicaciones. Elimina una de las tiendas en conflicto para gestionar esta aplicación.",
"app-store.ask-owner-to-install": "Pídele al propietario que instale esta aplicación",
"app-store.ask-owner-to-update": "Pídele al propietario que actualice esta aplicación",
"app-store.browse-category-apps": "Explorar aplicaciones de {{category}}",
"app-store.category.ai": "IA",
"app-store.category.all": "Todas las aplicaciones",
@@ -77,15 +79,33 @@
"app-store.category.media": "Medios",
"app-store.category.networking": "Redes",
"app-store.category.social": "Social",
"app-store.dependency-metadata-unavailable": "Metadatos no disponibles",
"app-store.description": "Tus configuraciones de actualización de aplicaciones",
"app-store.discover.temporarily-unavailable-description": "Explora las categorías de arriba o usa la búsqueda para encontrar aplicaciones",
"app-store.discover.temporarily-unavailable-title": "Contenido destacado temporalmente no disponible",
"app-store.discover-unavailable": "Descubrir no está disponible ahora mismo",
"app-store.featured-apps": "Aplicaciones destacadas",
"app-store.info.support": "Soporte",
"app-store.menu.community-app-stores": "Tiendas de Aplicaciones Comunitarias",
"app-store.release-timeline.no-notes": "Esta versión no tiene notas de la versión.",
"app-store.search-apps": "Buscar aplicaciones",
"app-store.search.clear": "Borrar búsqueda",
"app-store.search.no-results": "Sin resultados",
"app-store.search.no-results-description": "Prueba con otra búsqueda o explora las categorías de arriba.",
"app-store.search.result-count_one": "{{count}} resultado",
"app-store.search.result-count_other": "{{count}} resultados",
"app-store.search.results-for": "Resultados para",
"app-store.section.all-apps": "Todas las aplicaciones",
"app-store.sort.label": "Ordenar",
"app-store.sort.name": "Alfabéticamente",
"app-store.sort.newest": "Añadidas recientemente",
"app-store.sort.recently-updated": "Actualizadas recientemente",
"app-store.status.in-progress": "En curso",
"app-store.status.incompatible": "No compatible",
"app-store.status.update-available": "Actualización disponible",
"app-store.title": "App Store",
"app-store.updates": "Actualizaciones",
"app-store.updates-shelf.names-and-more": "{{names}} y más",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} actualización requiere una versión más reciente de umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} actualizaciones requieren una versión más reciente de umbrelOS",
"app-updates.less": "menos",
"app-updates.more": "más",
"app-updates.no-updates": "¡Todas las aplicaciones están actualizadas!",
@@ -100,7 +120,6 @@
"app.installing": "Instalando",
"app.offline": "No en ejecución",
"app.open": "Abrir",
"app.optimized-for-umbrel-home": "Optimizado para Umbrel Home",
"app.os-update-required.confirm": "Buscar actualización de umbrelOS",
"app.os-update-required.description": "{{appName}} requiere umbrelOS {{version}} o posterior",
"app.os-update-required.title": "Actualizar umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "No se pudo reiniciar la aplicación",
"app.toast.start-failed": "No se pudo iniciar la aplicación",
"app.toast.stop-failed": "No se pudo detener la aplicación",
"app.toast.update-failed": "No se pudo actualizar la aplicación",
"app.uninstall.confirm.description": "Todos los datos asociados con {{app}} serán eliminados permanentemente. Esta acción no se puede deshacer.",
"app.uninstall.confirm.submit": "Desinstalar",
"app.uninstall.confirm.title": "¿Desinstalar {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Configurar en",
"https-access-view-instructions": "Cómo usar HTTPS",
"install-first.install-app": "Instalar {{app}}",
"install-first.title": "{{app}} requiere estas aplicaciones",
"install-first.title_one": "{{app}} requiere esta aplicación",
"install-first.title_other": "{{app}} requiere estas aplicaciones",
"install-your-first-app": "Instala tu primera aplicación",
"language": "Idioma",
"language-description": "Tu idioma preferido para umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, võrguseaded ja palju muud",
"app-not-found": "Rakendust ei leitud: {{app}}",
"app-only-over-tor": "{{app}} saab kasutada ainult üle Tori. Selle rakenduse avamiseks ava oma Umbrel Tor-brauseris, kasutades kaugjuurdepääsu URL-i (Seaded > Täpsemad seaded > Tor-kaugjuurdepääs).",
"app-page.section.about": "Teave",
"app-page.section.about": "Teave rakenduse kohta",
"app-page.section.credentials.title": "Vaikimisi sisselogimisandmed",
"app-page.section.dependencies.n-alternatives": "Vaata {{count}} alternatiivi",
"app-page.section.info.compatibility": "Ühilduvus",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Avalik",
"app-page.section.info.submitted-by": "Esitaja",
"app-page.section.info.support": "Hangi abi",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Versioon",
"app-page.section.recommendations.title": "Sulle võib meeldida ka",
"app-page.section.release-notes.title": "Mis on uut",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Rakendust ei saanud jagada",
"app-share-users.title": "Jaga „{{name}}“ kasutajatega",
"app-share-users.unshare-failed": "Rakenduse jagamist ei saanud lõpetada",
"app-store.all-category-apps": "Kõik kategooria {{category}} rakendused",
"app-store.app-id-conflict": "Selle rakenduse ID on vastuolus teise rakenduste poe omaga. Selle rakenduse haldamiseks eemalda üks vastuolus olevatest poodidest.",
"app-store.ask-owner-to-install": "Palu omanikul see rakendus installida",
"app-store.ask-owner-to-update": "Palu omanikul seda rakendust värskendada",
"app-store.browse-category-apps": "Sirvi kategooria {{category}} rakendusi",
"app-store.category.ai": "AI",
"app-store.category.all": "Kõik rakendused",
@@ -77,15 +79,33 @@
"app-store.category.media": "Meedia",
"app-store.category.networking": "Võrgundus",
"app-store.category.social": "Sotsiaal",
"app-store.dependency-metadata-unavailable": "Metaandmed pole saadaval",
"app-store.description": "Sinu rakenduste värskendamise seaded",
"app-store.discover.temporarily-unavailable-description": "Rakenduste leidmiseks sirvi ülal kategooriaid või kasuta otsingut",
"app-store.discover.temporarily-unavailable-title": "Esiletõstetud sisu on ajutiselt saadaval mitte",
"app-store.discover-unavailable": "Avasta pole praegu saadaval",
"app-store.featured-apps": "Esiletõstetud rakendused",
"app-store.info.support": "Tugi",
"app-store.menu.community-app-stores": "Kogukonna App Store'id",
"app-store.release-timeline.no-notes": "Selle versiooni väljalaskemärkmed puuduvad.",
"app-store.search-apps": "Otsi rakendusi",
"app-store.search.clear": "Tühjenda otsing",
"app-store.search.no-results": "Tulemusi pole",
"app-store.search.no-results-description": "Proovi teist otsingut või sirvi ülalolevaid kategooriaid.",
"app-store.search.result-count_one": "{{count}} tulemus",
"app-store.search.result-count_other": "{{count}} tulemust",
"app-store.search.results-for": "Tulemused päringule",
"app-store.section.all-apps": "Kõik rakendused",
"app-store.sort.label": "Sordi",
"app-store.sort.name": "Tähestiku järgi",
"app-store.sort.newest": "Hiljuti lisatud",
"app-store.sort.recently-updated": "Hiljuti värskendatud",
"app-store.status.in-progress": "Pooleli",
"app-store.status.incompatible": "Ühildumatu",
"app-store.status.update-available": "Värskendus saadaval",
"app-store.title": "App Store",
"app-store.updates": "Uuendused",
"app-store.updates-shelf.names-and-more": "{{names}} ja veel",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} värskendus vajab umbrelOS-i uuemat versiooni",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} värskendust vajavad umbrelOS-i uuemat versiooni",
"app-updates.less": "vähem",
"app-updates.more": "rohkem",
"app-updates.no-updates": "Kõik rakendused on ajakohased!",
@@ -100,7 +120,6 @@
"app.installing": "Paigaldamine",
"app.offline": "Ei tööta",
"app.open": "Ava",
"app.optimized-for-umbrel-home": "Optimeeritud Umbrel Home'i jaoks",
"app.os-update-required.confirm": "Kontrolli umbrelOS-i uuendust",
"app.os-update-required.description": "{{appName}} vajab umbrelOS-i versiooni {{version}} või uuemat",
"app.os-update-required.title": "Uuenda umbrelOS-i",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Rakenduse taaskäivitamine ebaõnnestus",
"app.toast.start-failed": "Rakenduse käivitamine ebaõnnestus",
"app.toast.stop-failed": "Rakenduse peatamine ebaõnnestus",
"app.toast.update-failed": "Rakenduse värskendamine ebaõnnestus",
"app.uninstall.confirm.description": "Kõik rakendusega {{app}} seotud andmed kustutatakse jäädavalt. Seda tegevust ei saa tagasi võtta.",
"app.uninstall.confirm.submit": "Eemalda",
"app.uninstall.confirm.title": "Kas eemaldada {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Seadista seadmel",
"https-access-view-instructions": "Kuidas HTTPS-i kasutada",
"install-first.install-app": "Paigalda {{app}}",
"install-first.title": "{{app}} vajab neid rakendusi",
"install-first.title_one": "{{app}} vajab seda rakendust",
"install-first.title_other": "{{app}} vajab neid rakendusi",
"install-your-first-app": "Paigalda oma esimene rakendus",
"language": "Keel",
"language-description": "Sinu eelistatud umbrelOS-i keel",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, Programme bêta umbrelOS, paramètres réseau et plus",
"app-not-found": "Application non trouvée : {{app}}",
"app-only-over-tor": "{{app}} ne peut être utilisé que via Tor. Accédez à votre Umbrel depuis un navigateur Tor en utilisant votre URL d'accès à distance (Paramètres > Paramètres avancés > Accès distant via Tor) pour ouvrir cette application.",
"app-page.section.about": "À propos",
"app-page.section.about": "À propos de cette application",
"app-page.section.credentials.title": "Identifiants par défaut",
"app-page.section.dependencies.n-alternatives": "Voir {{count}} alternatives",
"app-page.section.info.compatibility": "Compatibilité",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Public",
"app-page.section.info.submitted-by": "Soumis par",
"app-page.section.info.support": "Obtenir de l'aide",
"app-page.section.info.title": "Infos",
"app-page.section.info.version": "Version",
"app-page.section.recommendations.title": "Vous pourriez aussi aimer",
"app-page.section.release-notes.title": "Nouveautés",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Impossible de partager l’application",
"app-share-users.title": "Partager « {{name}} » avec des utilisateurs",
"app-share-users.unshare-failed": "Impossible d’arrêter de partager l’application",
"app-store.all-category-apps": "Toutes les applications de la catégorie {{category}}",
"app-store.app-id-conflict": "L’identifiant de cette application entre en conflit avec celui d’un autre App Store. Supprimez l’un des App Store en conflit pour gérer cette application.",
"app-store.ask-owner-to-install": "Veuillez demander au propriétaire d’installer cette application",
"app-store.ask-owner-to-update": "Demandez au propriétaire de mettre à jour cette application",
"app-store.browse-category-apps": "Parcourir les applications {{category}}",
"app-store.category.ai": "IA",
"app-store.category.all": "Toutes les applications",
@@ -77,15 +79,33 @@
"app-store.category.media": "Médias",
"app-store.category.networking": "Réseautage",
"app-store.category.social": "Social",
"app-store.dependency-metadata-unavailable": "Métadonnées indisponibles",
"app-store.description": "Vos paramètres de mise à jour des applications",
"app-store.discover.temporarily-unavailable-description": "Parcourez les catégories ci-dessus ou utilisez la recherche pour trouver des apps",
"app-store.discover.temporarily-unavailable-title": "Contenu mis en avant momentanément indisponible",
"app-store.discover-unavailable": "Découvrir n’est pas disponible pour le moment",
"app-store.featured-apps": "Applications à la une",
"app-store.info.support": "Assistance",
"app-store.menu.community-app-stores": "App Stores Communautaires",
"app-store.release-timeline.no-notes": "Aucune note de version pour cette version.",
"app-store.search-apps": "Rechercher des applications",
"app-store.search.clear": "Effacer la recherche",
"app-store.search.no-results": "Aucun résultat",
"app-store.search.no-results-description": "Essayez une autre recherche ou parcourez les catégories ci-dessus.",
"app-store.search.result-count_one": "{{count}} résultat",
"app-store.search.result-count_other": "{{count}} résultats",
"app-store.search.results-for": "Résultats pour",
"app-store.section.all-apps": "Toutes les applications",
"app-store.sort.label": "Trier",
"app-store.sort.name": "Ordre alphabétique",
"app-store.sort.newest": "Ajoutées récemment",
"app-store.sort.recently-updated": "Mises à jour récemment",
"app-store.status.in-progress": "En cours",
"app-store.status.incompatible": "Non compatible",
"app-store.status.update-available": "Mise à jour disponible",
"app-store.title": "App Store",
"app-store.updates": "Mises à jour",
"app-store.updates-shelf.names-and-more": "{{names}}, et plus encore",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} mise à jour nécessite une version plus récente d’umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} mises à jour nécessitent une version plus récente d’umbrelOS",
"app-updates.less": "moins",
"app-updates.more": "plus",
"app-updates.no-updates": "Toutes les applications sont à jour !",
@@ -100,7 +120,6 @@
"app.installing": "Installation",
"app.offline": "Non en cours d'exécution",
"app.open": "Ouvrir",
"app.optimized-for-umbrel-home": "Optimisée pour Umbrel Home",
"app.os-update-required.confirm": "Vérifier les mises à jour d'umbrelOS",
"app.os-update-required.description": "{{appName}} nécessite umbrelOS {{version}} ou une version ultérieure",
"app.os-update-required.title": "Mettre à jour umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Impossible de redémarrer l’application",
"app.toast.start-failed": "Impossible de démarrer l’application",
"app.toast.stop-failed": "Impossible d’arrêter l’application",
"app.toast.update-failed": "Échec de la mise à jour de l’application",
"app.uninstall.confirm.description": "Toutes les données associées à {{app}} seront définitivement supprimées. Cette action est irréversible.",
"app.uninstall.confirm.submit": "Désinstaller",
"app.uninstall.confirm.title": "Désinstaller {{app}} ?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Configurer sur",
"https-access-view-instructions": "Comment utiliser HTTPS",
"install-first.install-app": "Installez {{app}}",
"install-first.title": "{{app}} nécessite ces applications",
"install-first.title_one": "{{app}} nécessite cette application",
"install-first.title_other": "{{app}} nécessite ces applications",
"install-your-first-app": "Installez votre première application",
"language": "Langue",
"language-description": "Votre langue préférée pour umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, mrežne postavke i još mnogo toga",
"app-not-found": "Aplikacija nije pronađena: {{app}}",
"app-only-over-tor": "{{app}} se može koristiti samo preko Tor mreže. Otvori svoj Umbrel u Tor pregledniku na URL-u za udaljeni pristup (Postavke > Napredne postavke > Udaljeni Tor pristup) kako bi otvorio ovu aplikaciju.",
"app-page.section.about": "O aplikaciji",
"app-page.section.about": "O ovoj aplikaciji",
"app-page.section.credentials.title": "Zadani podaci za prijavu",
"app-page.section.dependencies.n-alternatives": "Pogledaj {{count}} alternativa",
"app-page.section.info.compatibility": "Kompatibilnost",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Javno",
"app-page.section.info.submitted-by": "Poslao/la",
"app-page.section.info.support": "Zatraži podršku",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Verzija",
"app-page.section.recommendations.title": "Možda će ti se svidjeti",
"app-page.section.release-notes.title": "Što je novo",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Nije moguće podijeliti aplikaciju",
"app-share-users.title": "Podijeli “{{name}}” s korisnicima",
"app-share-users.unshare-failed": "Nije moguće prekinuti dijeljenje aplikacije",
"app-store.all-category-apps": "Sve aplikacije kategorije {{category}}",
"app-store.app-id-conflict": "Ovaj ID aplikacije u sukobu je s drugim App Storeom. Ukloni jedan od App Storeova u sukobu kako bi upravljao ovom aplikacijom.",
"app-store.ask-owner-to-install": "Zamoli vlasnika da instalira ovu aplikaciju",
"app-store.ask-owner-to-update": "Zamoli vlasnika da ažurira ovu aplikaciju",
"app-store.browse-category-apps": "Pregledaj aplikacije u kategoriji {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Sve aplikacije",
@@ -77,15 +79,33 @@
"app-store.category.media": "Mediji",
"app-store.category.networking": "Mreža",
"app-store.category.social": "Društvene mreže",
"app-store.dependency-metadata-unavailable": "Metapodaci nisu dostupni",
"app-store.description": "Postavke ažuriranja aplikacija",
"app-store.discover.temporarily-unavailable-description": "Pregledaj kategorije iznad ili pretraži kako bi pronašao aplikacije",
"app-store.discover.temporarily-unavailable-title": "Istaknuti sadržaj trenutačno nije dostupan",
"app-store.discover-unavailable": "Discover trenutačno nije dostupan",
"app-store.featured-apps": "Istaknute aplikacije",
"app-store.info.support": "Podrška",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Nema napomena o izdanju za ovu verziju.",
"app-store.search-apps": "Pretraži aplikacije",
"app-store.search.clear": "Očisti pretraživanje",
"app-store.search.no-results": "Nema rezultata",
"app-store.search.no-results-description": "Pokušaj pretražiti nešto drugo ili pregledaj gore navedene kategorije.",
"app-store.search.result-count_one": "{{count}} rezultat",
"app-store.search.result-count_other": "{{count}} rezultata",
"app-store.search.results-for": "Rezultati za",
"app-store.section.all-apps": "Sve aplikacije",
"app-store.sort.label": "Razvrstaj",
"app-store.sort.name": "Abecedno",
"app-store.sort.newest": "Nedavno dodane",
"app-store.sort.recently-updated": "Nedavno ažurirane",
"app-store.status.in-progress": "U tijeku",
"app-store.status.incompatible": "Nekompatibilno",
"app-store.status.update-available": "Dostupno ažuriranje",
"app-store.title": "App Store",
"app-store.updates": "Ažuriranja",
"app-store.updates-shelf.names-and-more": "{{names}} i još mnogo toga",
"app-store.updates-shelf.os-update-required-count_one": "Za {{count}} ažuriranje potrebna je novija verzija umbrelOS-a",
"app-store.updates-shelf.os-update-required-count_other": "Novija verzija umbrelOS-a potrebna je za {{count}} ažuriranja",
"app-updates.less": "manje",
"app-updates.more": "više",
"app-updates.no-updates": "Sve su aplikacije ažurne!",
@@ -100,7 +120,6 @@
"app.installing": "Instaliranje",
"app.offline": "Nije pokrenuto",
"app.open": "Otvori",
"app.optimized-for-umbrel-home": "Optimizirano za Umbrel Home",
"app.os-update-required.confirm": "Provjeri ima li ažuriranja za umbrelOS",
"app.os-update-required.description": "{{appName}} zahtijeva umbrelOS {{version}} ili noviji",
"app.os-update-required.title": "Ažuriraj umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Ponovno pokretanje aplikacije nije uspjelo",
"app.toast.start-failed": "Pokretanje aplikacije nije uspjelo",
"app.toast.stop-failed": "Zaustavljanje aplikacije nije uspjelo",
"app.toast.update-failed": "Ažuriranje aplikacije nije uspjelo",
"app.uninstall.confirm.description": "Svi podaci povezani s aplikacijom {{app}} bit će trajno izbrisani. Ovu radnju nije moguće poništiti.",
"app.uninstall.confirm.submit": "Deinstaliraj",
"app.uninstall.confirm.title": "Deinstalirati {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Postavi na",
"https-access-view-instructions": "Kako koristiti HTTPS",
"install-first.install-app": "Instaliraj {{app}}",
"install-first.title": "{{app}} zahtijeva ove aplikacije",
"install-first.title_one": "Aplikacija {{app}} zahtijeva ovu aplikaciju",
"install-first.title_other": "Aplikacija {{app}} zahtijeva ove aplikacije",
"install-your-first-app": "Instaliraj svoju prvu aplikaciju",
"language": "Jezik",
"language-description": "Preferirani jezik umbrelOS-a",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminál, az umbrelOS béta program, hálózati beállítások és még több",
"app-not-found": "Alkalmazás nem található: {{app}}",
"app-only-over-tor": "{{app}} csak Toron keresztül használható. Kérlek, nyisd meg az Umbrel-t egy Tor böngészőben a távoli hozzáférési URL-eden (Beállítások > Speciális beállítások > Távoli Tor-hozzáférés), hogy megnyisd ezt az alkalmazást.",
"app-page.section.about": "Rólunk",
"app-page.section.about": "Az alkalmazásról",
"app-page.section.credentials.title": "Alapértelmezett hitelesítési adatok",
"app-page.section.dependencies.n-alternatives": "Lásd a {{count}} alternatívát",
"app-page.section.info.compatibility": "Kompatibilitás",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Nyilvános",
"app-page.section.info.submitted-by": "Beküldte",
"app-page.section.info.support": "Támogatás kérése",
"app-page.section.info.title": "Információ",
"app-page.section.info.version": "Verzió",
"app-page.section.recommendations.title": "Neked is tetszhet",
"app-page.section.release-notes.title": "Újdonságok",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Nem sikerült megosztani az alkalmazást",
"app-share-users.title": "„{{name}}” megosztása felhasználókkal",
"app-share-users.unshare-failed": "Nem sikerült megszüntetni az alkalmazás megosztását",
"app-store.all-category-apps": "Összes {{category}} alkalmazás",
"app-store.app-id-conflict": "Ezt az alkalmazásazonosítót egy másik App Store is használja. Az alkalmazás kezeléséhez távolítsd el az ütköző App Store-ok egyikét.",
"app-store.ask-owner-to-install": "Kérd meg a tulajdonost, hogy telepítse ezt az alkalmazást",
"app-store.ask-owner-to-update": "Kérd meg a tulajdonost, hogy frissítse ezt az alkalmazást",
"app-store.browse-category-apps": "Böngészd a(z) {{category}} alkalmazásokat",
"app-store.category.ai": "AI",
"app-store.category.all": "Összes alkalmazás",
@@ -77,15 +79,33 @@
"app-store.category.media": "Média",
"app-store.category.networking": "Hálózat",
"app-store.category.social": "Közösségi",
"app-store.dependency-metadata-unavailable": "A metaadatok nem érhetők el",
"app-store.description": "Az alkalmazásfrissítési beállításaid",
"app-store.discover.temporarily-unavailable-description": "Böngészd a fenti kategóriákat, vagy keress, hogy alkalmazásokat találj",
"app-store.discover.temporarily-unavailable-title": "A kiemelt tartalom jelenleg nem érhető el",
"app-store.discover-unavailable": "A Felfedezés jelenleg nem érhető el",
"app-store.featured-apps": "Kiemelt alkalmazások",
"app-store.info.support": "Támogatás",
"app-store.menu.community-app-stores": "Közösségi alkalmazásboltok",
"app-store.release-timeline.no-notes": "Ehhez a verzióhoz nincsenek kiadási megjegyzések.",
"app-store.search-apps": "Alkalmazások keresése",
"app-store.search.clear": "Keresés törlése",
"app-store.search.no-results": "Nincs találat",
"app-store.search.no-results-description": "Próbálj másképp keresni, vagy böngéssz a fenti kategóriák között.",
"app-store.search.result-count_one": "{{count}} találat",
"app-store.search.result-count_other": "Találatok: {{count}}",
"app-store.search.results-for": "Eredmények erre",
"app-store.section.all-apps": "Összes alkalmazás",
"app-store.sort.label": "Rendezés",
"app-store.sort.name": "Betűrendben",
"app-store.sort.newest": "Nemrég hozzáadva",
"app-store.sort.recently-updated": "Nemrég frissítve",
"app-store.status.in-progress": "Folyamatban",
"app-store.status.incompatible": "Nem kompatibilis",
"app-store.status.update-available": "Frissítés érhető el",
"app-store.title": "Alkalmazásbolt",
"app-store.updates": "Frissítések",
"app-store.updates-shelf.names-and-more": "{{names}} és továbbiak",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} frissítéshez az umbrelOS újabb verziója szükséges",
"app-store.updates-shelf.os-update-required-count_other": "Az umbrelOS újabb verzióját igénylő frissítések száma: {{count}}",
"app-updates.less": "kevesebb",
"app-updates.more": "több",
"app-updates.no-updates": "Minden alkalmazás naprakész!",
@@ -100,7 +120,6 @@
"app.installing": "Telepítés folyamatban",
"app.offline": "Nem fut",
"app.open": "Megnyitás",
"app.optimized-for-umbrel-home": "Optimalizált az Umbrel Home-hoz",
"app.os-update-required.confirm": "Ellenőrizd az umbrelOS frissítését",
"app.os-update-required.description": "A(z) {{appName}} alkalmazásnak umbrelOS {{version}} vagy újabb verzióra van szüksége",
"app.os-update-required.title": "Frissítsd az umbrelOS-t",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Nem sikerült újraindítani az alkalmazást",
"app.toast.start-failed": "Nem sikerült elindítani az alkalmazást",
"app.toast.stop-failed": "Nem sikerült leállítani az alkalmazást",
"app.toast.update-failed": "Nem sikerült frissíteni az alkalmazást",
"app.uninstall.confirm.description": "Az {{app}}-hez tartozó összes adat véglegesen törlésre kerül. Ez a művelet nem vonható vissza.",
"app.uninstall.confirm.submit": "Eltávolítás",
"app.uninstall.confirm.title": "{{app}} eltávolítása?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Beállítás ezen",
"https-access-view-instructions": "A HTTPS használata",
"install-first.install-app": "Telepítsd a(z) {{app}} alkalmazást",
"install-first.title": "{{app}} a következő alkalmazásokat igényli",
"install-first.title_one": "A(z) {{app}} alkalmazásnak szüksége van erre az alkalmazásra",
"install-first.title_other": "A(z) {{app}} alkalmazásnak szüksége van ezekre az alkalmazásokra",
"install-your-first-app": "Telepítsd az első alkalmazásod",
"language": "Nyelv",
"language-description": "Az általad preferált umbrelOS nyelv",
+28 -7
View File
@@ -32,18 +32,17 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, netkerfisstillingar og fleira",
"app-not-found": "Forrit fannst ekki: {{app}}",
"app-only-over-tor": "{{app}} er aðeins hægt að nota yfir Tor. Farðu á Umbrel í Tor-vafra með ytri aðgangsslóðinni þinni (Settings > Advanced settings > Remote Tor access) til að opna þetta forrit.",
"app-page.section.about": "Um",
"app-page.section.about": "Um þetta forrit",
"app-page.section.credentials.title": "Sjálfgefnar aðgangsupplýsingar",
"app-page.section.dependencies.n-alternatives": "Sjá {{count}} valkosti",
"app-page.section.info.compatibility": "Samhæfni",
"app-page.section.info.compatibility-compatible": "Samhæft",
"app-page.section.info.compatibility-not-compatible": "Ekki samhæft",
"app-page.section.info.developer": "Þróað af",
"app-page.section.info.source-code": "Kóðagrunnur",
"app-page.section.info.source-code": "Frumkóði",
"app-page.section.info.source-code.public": "Opinber",
"app-page.section.info.submitted-by": "Sent inn af",
"app-page.section.info.support": "Fá aðstoð",
"app-page.section.info.title": "Upplýsingar",
"app-page.section.info.version": "Útgáfa",
"app-page.section.recommendations.title": "Þér gæti líka líkað þetta",
"app-page.section.release-notes.title": "Hvað er nýtt",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Ekki tókst að deila forriti",
"app-share-users.title": "Deila „{{name}}“ með notendum",
"app-share-users.unshare-failed": "Ekki tókst að hætta að deila forriti",
"app-store.all-category-apps": "Öll forrit í flokknum {{category}}",
"app-store.app-id-conflict": "Auðkenni þessa forrits stangast á við auðkenni í annarri forritaverslun. Fjarlægðu eina af forritaverslununum sem stangast á til að stjórna þessu forriti.",
"app-store.ask-owner-to-install": "Biddu eigandann um að setja þetta forrit upp",
"app-store.ask-owner-to-update": "Biddu eigandann að uppfæra þetta forrit",
"app-store.browse-category-apps": "Skoða forrit í flokknum {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Öll forrit",
@@ -77,15 +79,33 @@
"app-store.category.media": "Miðlar",
"app-store.category.networking": "Netkerfi",
"app-store.category.social": "Samskipti",
"app-store.dependency-metadata-unavailable": "Lýsigögn ekki tiltæk",
"app-store.description": "Uppfærslustillingar forrita",
"app-store.discover.temporarily-unavailable-description": "Skoðaðu flokkana hér að ofan eða leitaðu til að finna forrit",
"app-store.discover.temporarily-unavailable-title": "Valið efni er tímabundið óaðgengilegt",
"app-store.discover-unavailable": "Uppgötva er ekki í boði núna",
"app-store.featured-apps": "Valin forrit",
"app-store.info.support": "Aðstoð",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Engar útgáfuupplýsingar fyrir þessa útgáfu.",
"app-store.search-apps": "Leita að forritum",
"app-store.search.clear": "Hreinsa leit",
"app-store.search.no-results": "Engar niðurstöður",
"app-store.search.no-results-description": "Prófaðu aðra leit eða skoðaðu flokkana hér fyrir ofan.",
"app-store.search.result-count_one": "{{count}} niðurstaða",
"app-store.search.result-count_other": "{{count}} niðurstöður",
"app-store.search.results-for": "Niðurstöður fyrir",
"app-store.section.all-apps": "Öll forrit",
"app-store.sort.label": "Raða",
"app-store.sort.name": "Í stafrófsröð",
"app-store.sort.newest": "Nýlega bætt við",
"app-store.sort.recently-updated": "Nýlega uppfært",
"app-store.status.in-progress": "Í vinnslu",
"app-store.status.incompatible": "Ekki samhæft",
"app-store.status.update-available": "Uppfærsla í boði",
"app-store.title": "App Store",
"app-store.updates": "Uppfærslur",
"app-store.updates-shelf.names-and-more": "{{names}} og fleiri",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} uppfærsla krefst nýrri útgáfu af umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} uppfærslur krefjast nýrri útgáfu af umbrelOS",
"app-updates.less": "minna",
"app-updates.more": "meira",
"app-updates.no-updates": "Öll forrit eru uppfærð!",
@@ -100,7 +120,6 @@
"app.installing": "Set upp",
"app.offline": "Ekki keyrt",
"app.open": "Opna",
"app.optimized-for-umbrel-home": "Bestað fyrir Umbrel Home",
"app.os-update-required.confirm": "Athuga umbrelOS uppfærslu",
"app.os-update-required.description": "{{appName}} krefst umbrelOS {{version}} eða nýrra",
"app.os-update-required.title": "Uppfæra umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Ekki tókst að endurræsa forritið",
"app.toast.start-failed": "Ekki tókst að ræsa forritið",
"app.toast.stop-failed": "Ekki tókst að stöðva forritið",
"app.toast.update-failed": "Ekki tókst að uppfæra forritið",
"app.uninstall.confirm.description": "Öllum gögnum sem tengjast {{app}} verður eytt varanlega. Það er ekki hægt að afturkalla þessa aðgerð.",
"app.uninstall.confirm.submit": "Fjarlægja",
"app.uninstall.confirm.title": "Fjarlægja {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Setja upp á",
"https-access-view-instructions": "Hvernig á að nota HTTPS",
"install-first.install-app": "Setja upp {{app}}",
"install-first.title": "{{app}} krefst þessara forrita",
"install-first.title_one": "{{app}} krefst þessa forrits",
"install-first.title_other": "{{app}} krefst þessara forrita",
"install-your-first-app": "Settu upp fyrsta forritið þitt",
"language": "Tungumál",
"language-description": "Valið tungumál umbrelOS",
+28 -7
View File
@@ -32,18 +32,17 @@
"advanced-settings-description": "Terminale, programma Beta di umbrelOS, impostazioni di rete e altro",
"app-not-found": "App non trovata: {{app}}",
"app-only-over-tor": "L'app {{app}} è utilizzabile solo tramite Tor. Per aprirla, accedi al tuo Umbrel con un browser Tor usando l'URL di accesso remoto (Impostazioni > Impostazioni avanzate > Accesso Tor remoto).",
"app-page.section.about": "Informazioni",
"app-page.section.about": "Informazioni su questa app",
"app-page.section.credentials.title": "Credenziali predefinite",
"app-page.section.dependencies.n-alternatives": "Vedi {{count}} alternative",
"app-page.section.info.compatibility": "Compatibilità",
"app-page.section.info.compatibility-compatible": "Compatibile",
"app-page.section.info.compatibility-not-compatible": "Non compatibile",
"app-page.section.info.developer": "Sviluppatore",
"app-page.section.info.source-code": "Codice Sorgente",
"app-page.section.info.source-code": "Codice sorgente",
"app-page.section.info.source-code.public": "Pubblico",
"app-page.section.info.submitted-by": "Inviato da",
"app-page.section.info.support": "Ottieni supporto",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Versione",
"app-page.section.recommendations.title": "Potrebbero interessarti anche",
"app-page.section.release-notes.title": "Novità",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Impossibile condividere l'app",
"app-share-users.title": "Condividi “{{name}}” con gli utenti",
"app-share-users.unshare-failed": "Impossibile interrompere la condivisione dell'app",
"app-store.all-category-apps": "Tutte le app {{category}}",
"app-store.app-id-conflict": "Questo ID app è in conflitto con un altro app store. Rimuovi uno degli store in conflitto per gestire questa app.",
"app-store.ask-owner-to-install": "Chiedi al proprietario di installare quest'app",
"app-store.ask-owner-to-update": "Chiedi al proprietario di aggiornare questa app",
"app-store.browse-category-apps": "Esplora le app {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Tutte le app",
@@ -77,15 +79,33 @@
"app-store.category.media": "Media",
"app-store.category.networking": "Networking",
"app-store.category.social": "Social",
"app-store.dependency-metadata-unavailable": "Metadati non disponibili",
"app-store.description": "Le tue impostazioni di aggiornamento app",
"app-store.discover.temporarily-unavailable-description": "Sfoglia le categorie qui sopra o usa la ricerca per trovare app",
"app-store.discover.temporarily-unavailable-title": "Contenuti in evidenza temporaneamente non disponibili",
"app-store.discover-unavailable": "Scopri non è disponibile al momento",
"app-store.featured-apps": "App in evidenza",
"app-store.info.support": "Assistenza",
"app-store.menu.community-app-stores": "Community App Store",
"app-store.release-timeline.no-notes": "Nessuna nota di rilascio per questa versione.",
"app-store.search-apps": "Cerca app",
"app-store.search.clear": "Cancella ricerca",
"app-store.search.no-results": "Nessun risultato",
"app-store.search.no-results-description": "Prova una ricerca diversa o sfoglia le categorie qui sopra.",
"app-store.search.result-count_one": "{{count}} risultato",
"app-store.search.result-count_other": "{{count}} risultati",
"app-store.search.results-for": "Risultati per",
"app-store.section.all-apps": "Tutte le app",
"app-store.sort.label": "Ordina",
"app-store.sort.name": "In ordine alfabetico",
"app-store.sort.newest": "Aggiunte di recente",
"app-store.sort.recently-updated": "Aggiornate di recente",
"app-store.status.in-progress": "In corso",
"app-store.status.incompatible": "Non compatibile",
"app-store.status.update-available": "Aggiornamento disponibile",
"app-store.title": "App Store",
"app-store.updates": "Aggiornamenti",
"app-store.updates-shelf.names-and-more": "{{names}} e altro",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} aggiornamento richiede una versione più recente di umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} aggiornamenti richiedono una versione più recente di umbrelOS",
"app-updates.less": "meno",
"app-updates.more": "più",
"app-updates.no-updates": "Tutte le app sono aggiornate!",
@@ -100,7 +120,6 @@
"app.installing": "Installazione",
"app.offline": "Non in esecuzione",
"app.open": "Apri",
"app.optimized-for-umbrel-home": "Ottimizzato per Umbrel Home",
"app.os-update-required.confirm": "Controlla aggiornamento di umbrelOS",
"app.os-update-required.description": "{{appName}} richiede umbrelOS {{version}} o successivo",
"app.os-update-required.title": "Aggiorna umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Impossibile riavviare l'app",
"app.toast.start-failed": "Impossibile avviare l'app",
"app.toast.stop-failed": "Impossibile arrestare l'app",
"app.toast.update-failed": "Impossibile aggiornare l’app",
"app.uninstall.confirm.description": "Tutti i dati associati a {{app}} verranno eliminati permanentemente. Questa azione non può essere annullata.",
"app.uninstall.confirm.submit": "Disinstalla",
"app.uninstall.confirm.title": "Disinstallare {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Configura su",
"https-access-view-instructions": "Come usare HTTPS",
"install-first.install-app": "Installa {{app}}",
"install-first.title": "{{app}} richiede queste app",
"install-first.title_one": "{{app}} richiede questa app",
"install-first.title_other": "{{app}} richiede queste app",
"install-your-first-app": "Installa la tua prima app",
"language": "Lingua",
"language-description": "La tua lingua preferita per umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "ターミナル、umbrelOSベータプログラム、ネットワーク設定など",
"app-not-found": "アプリが見つかりません: {{app}}",
"app-only-over-tor": "{{app}}はTor経由でのみ利用できます。このアプリを開くには、リモートアクセス用のURL(設定 > 詳細設定 > リモート Tor アクセス)にあるUmbrelへTorブラウザでアクセスしてください。",
"app-page.section.about": "約",
"app-page.section.about": "このアプリについて",
"app-page.section.credentials.title": "デフォルト資格情報",
"app-page.section.dependencies.n-alternatives": "{{count}} つの代替案を表示",
"app-page.section.info.compatibility": "互換性",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "公開",
"app-page.section.info.submitted-by": "提出者",
"app-page.section.info.support": "サポートを受ける",
"app-page.section.info.title": "情報",
"app-page.section.info.version": "バージョン",
"app-page.section.recommendations.title": "おすすめ",
"app-page.section.release-notes.title": "新機能",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "アプリを共有できませんでした",
"app-share-users.title": "「{{name}}」をユーザーと共有",
"app-share-users.unshare-failed": "アプリの共有を停止できませんでした",
"app-store.all-category-apps": "{{category}}のすべてのアプリ",
"app-store.app-id-conflict": "このアプリIDは別のApp Storeと競合しています。このアプリを管理するには、競合しているストアのいずれかを削除してください。",
"app-store.ask-owner-to-install": "このアプリをインストールするよう、オーナーに依頼してください",
"app-store.ask-owner-to-update": "オーナーにこのアプリのアップデートを依頼してください",
"app-store.browse-category-apps": "{{category}}アプリを閲覧",
"app-store.category.ai": "AI",
"app-store.category.all": "すべてのアプリ",
@@ -77,15 +79,33 @@
"app-store.category.media": "メディア",
"app-store.category.networking": "ネットワーキング",
"app-store.category.social": "ソーシャル",
"app-store.dependency-metadata-unavailable": "メタデータを取得できません",
"app-store.description": "あなたのアプリ更新設定",
"app-store.discover.temporarily-unavailable-description": "上のカテゴリをブラウズするか、検索してアプリを見つけてください",
"app-store.discover.temporarily-unavailable-title": "特集コンテンツは一時的に利用できません",
"app-store.discover-unavailable": "Discoverは現在利用できません",
"app-store.featured-apps": "おすすめのアプリ",
"app-store.info.support": "サポート",
"app-store.menu.community-app-stores": "コミュニティApp Stores",
"app-store.release-timeline.no-notes": "このバージョンのリリースノートはありません。",
"app-store.search-apps": "アプリを検索",
"app-store.search.clear": "検索をクリア",
"app-store.search.no-results": "結果なし",
"app-store.search.no-results-description": "別のキーワードで検索するか、上のカテゴリーを参照してください。",
"app-store.search.result-count_one": "検索結果:{{count}}件",
"app-store.search.result-count_other": "{{count}}件の検索結果",
"app-store.search.results-for": "の結果",
"app-store.section.all-apps": "すべてのアプリ",
"app-store.sort.label": "並べ替え",
"app-store.sort.name": "アルファベット順",
"app-store.sort.newest": "新着順",
"app-store.sort.recently-updated": "更新が新しい順",
"app-store.status.in-progress": "進行中",
"app-store.status.incompatible": "非対応",
"app-store.status.update-available": "アップデートあり",
"app-store.title": "App Store",
"app-store.updates": "アップデート",
"app-store.updates-shelf.names-and-more": "{{names}}など",
"app-store.updates-shelf.os-update-required-count_one": "{{count}}件のアップデートには、新しいバージョンのumbrelOSが必要です",
"app-store.updates-shelf.os-update-required-count_other": "新しいバージョンのumbrelOSが必要なアップデートが{{count}}件あります",
"app-updates.less": "少なく",
"app-updates.more": "もっと",
"app-updates.no-updates": "すべてのアプリが最新です!",
@@ -100,7 +120,6 @@
"app.installing": "インストール中",
"app.offline": "実行されていません",
"app.open": "開く",
"app.optimized-for-umbrel-home": "Umbrel Homeに最適化",
"app.os-update-required.confirm": "umbrelOSのアップデートを確認",
"app.os-update-required.description": "{{appName}}はumbrelOS {{version}}以降が必要です",
"app.os-update-required.title": "umbrelOSをアップデート",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "アプリを再起動できませんでした",
"app.toast.start-failed": "アプリを起動できませんでした",
"app.toast.stop-failed": "アプリを停止できませんでした",
"app.toast.update-failed": "アプリをアップデートできませんでした",
"app.uninstall.confirm.description": "{{app}}に関連するすべてのデータが永久に削除されます。この操作は元に戻せません。",
"app.uninstall.confirm.submit": "アンインストール",
"app.uninstall.confirm.title": "{{app}}をアンインストールしますか?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "セットアップするデバイス",
"https-access-view-instructions": "HTTPS の使い方",
"install-first.install-app": "{{app}}をインストール",
"install-first.title": "{{app}}はこれらのアプリを必要とします",
"install-first.title_one": "{{app}}にはこのアプリが必要です",
"install-first.title_other": "{{app}}にはこれらのアプリが必要です",
"install-your-first-app": "最初のアプリをインストール",
"language": "言語",
"language-description": "あなたが好むumbrelOSの言語",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "터미널, umbrelOS Beta Program, 네트워크 설정 등",
"app-not-found": "앱을 찾을 수 없습니다: {{app}}",
"app-only-over-tor": "{{app}} 앱은 Tor 브라우저에서만 사용할 수 있어요. 이 앱을 열려면 원격 접속 URL(설정 > 고급 설정 > 원격 Tor 접속)에서 Tor 브라우저로 Umbrel에 접속하세요.",
"app-page.section.about": "정보",
"app-page.section.about": "앱 정보",
"app-page.section.credentials.title": "기본 자격 증명",
"app-page.section.dependencies.n-alternatives": "{{count}}개 대안을 확인하세요",
"app-page.section.info.compatibility": "호환성",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "공개",
"app-page.section.info.submitted-by": "제공자",
"app-page.section.info.support": "지원 받기",
"app-page.section.info.title": "정보",
"app-page.section.info.version": "버전",
"app-page.section.recommendations.title": "이 앱들도 추천해요",
"app-page.section.release-notes.title": "새로운 점",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "앱을 공유할 수 없어요",
"app-share-users.title": "사용자와 “{{name}}” 공유하기",
"app-share-users.unshare-failed": "앱 공유를 중지할 수 없어요",
"app-store.all-category-apps": "{{category}} 앱 전체",
"app-store.app-id-conflict": "이 앱 ID가 다른 앱 스토어와 충돌해요. 이 앱을 관리하려면 충돌하는 스토어 중 하나를 제거하세요.",
"app-store.ask-owner-to-install": "소유자에게 이 앱을 설치해 달라고 요청하세요",
"app-store.ask-owner-to-update": "소유자에게 이 앱을 업데이트해 달라고 요청하세요",
"app-store.browse-category-apps": "{{category}} 앱 둘러보기",
"app-store.category.ai": "AI",
"app-store.category.all": "전체 앱",
@@ -77,15 +79,33 @@
"app-store.category.media": "미디어",
"app-store.category.networking": "네트워킹",
"app-store.category.social": "소셜",
"app-store.dependency-metadata-unavailable": "메타데이터를 사용할 수 없음",
"app-store.description": "앱 업데이트 설정",
"app-store.discover.temporarily-unavailable-description": "위 카테고리를 둘러보거나 검색해서 앱을 찾아보세요",
"app-store.discover.temporarily-unavailable-title": "추천 콘텐츠를 일시적으로 이용할 수 없어요",
"app-store.discover-unavailable": "지금은 둘러보기를 사용할 수 없어요",
"app-store.featured-apps": "추천 앱",
"app-store.info.support": "지원",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "이 버전에는 릴리스 노트가 없어요.",
"app-store.search-apps": "앱 검색",
"app-store.search.clear": "검색어 지우기",
"app-store.search.no-results": "결과가 없습니다",
"app-store.search.no-results-description": "다른 검색어를 사용하거나 위의 카테고리를 둘러보세요.",
"app-store.search.result-count_one": "{{count}}개 결과",
"app-store.search.result-count_other": "검색 결과 {{count}}개",
"app-store.search.results-for": "다음에 대한 결과",
"app-store.section.all-apps": "모든 앱",
"app-store.sort.label": "정렬",
"app-store.sort.name": "이름순",
"app-store.sort.newest": "최근 추가순",
"app-store.sort.recently-updated": "최근 업데이트순",
"app-store.status.in-progress": "진행 중",
"app-store.status.incompatible": "호환되지 않음",
"app-store.status.update-available": "업데이트 가능",
"app-store.title": "App Store",
"app-store.updates": "업데이트",
"app-store.updates-shelf.names-and-more": "{{names}} 외",
"app-store.updates-shelf.os-update-required-count_one": "{{count}}개 업데이트에 더 최신 버전의 umbrelOS가 필요해요",
"app-store.updates-shelf.os-update-required-count_other": "더 최신 버전의 umbrelOS가 필요한 업데이트가 {{count}}개 있어요",
"app-updates.less": "간단히",
"app-updates.more": "자세히",
"app-updates.no-updates": "모든 앱이 최신 버전이에요!",
@@ -100,7 +120,6 @@
"app.installing": "설치 중...",
"app.offline": "실행 중이 아님",
"app.open": "열기",
"app.optimized-for-umbrel-home": "Umbrel Home에 최적화됨",
"app.os-update-required.confirm": "umbrelOS 업데이트 확인",
"app.os-update-required.description": "{{appName}}을(를) 사용하려면 umbrelOS {{version}} 이상이 필요합니다",
"app.os-update-required.title": "umbrelOS 업데이트",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "앱을 다시 시작하지 못했어요",
"app.toast.start-failed": "앱을 시작하지 못했어요",
"app.toast.stop-failed": "앱을 중지하지 못했어요",
"app.toast.update-failed": "앱을 업데이트하지 못했어요",
"app.uninstall.confirm.description": "{{app}}와 관련된 모든 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
"app.uninstall.confirm.submit": "제거",
"app.uninstall.confirm.title": "{{app}} 제거?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "설정할 기기",
"https-access-view-instructions": "HTTPS 사용 방법",
"install-first.install-app": "{{app}} 설치",
"install-first.title": "{{app}}을(를) 사용하려면 다음 앱들이 필요합니다",
"install-first.title_one": "{{app}}에 이 앱이 필요해요",
"install-first.title_other": "{{app}}에 이 앱들이 필요해요",
"install-your-first-app": "처음 앱을 설치해보세요",
"language": "언어",
"language-description": "원하는 umbrelOS 언어",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, nettverksinnstillinger og mer",
"app-not-found": "Fant ikke appen: {{app}}",
"app-only-over-tor": "{{app}} kan bare brukes over Tor. Gå til Umbrel i en Tor-nettleser på URL-en for ekstern tilgang (Innstillinger > Avanserte innstillinger > Remote Tor access) for å åpne denne appen.",
"app-page.section.about": "Om",
"app-page.section.about": "Om denne appen",
"app-page.section.credentials.title": "Standard legitimasjon",
"app-page.section.dependencies.n-alternatives": "Se {{count}} alternativer",
"app-page.section.info.compatibility": "Kompatibilitet",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Offentlig",
"app-page.section.info.submitted-by": "Sendt inn av",
"app-page.section.info.support": "Få hjelp",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Versjon",
"app-page.section.recommendations.title": "Du liker kanskje også",
"app-page.section.release-notes.title": "Hva er nytt",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Kunne ikke dele appen",
"app-share-users.title": "Del “{{name}}” med brukere",
"app-share-users.unshare-failed": "Kunne ikke stoppe delingen av appen",
"app-store.all-category-apps": "Alle apper i {{category}}",
"app-store.app-id-conflict": "Denne app-ID-en er i konflikt med en annen appbutikk. Fjern én av appbutikkene som er i konflikt, for å administrere denne appen.",
"app-store.ask-owner-to-install": "Be eieren om å installere denne appen",
"app-store.ask-owner-to-update": "Be eieren om å oppdatere denne appen",
"app-store.browse-category-apps": "Utforsk {{category}}-apper",
"app-store.category.ai": "AI",
"app-store.category.all": "Alle apper",
@@ -77,15 +79,33 @@
"app-store.category.media": "Media",
"app-store.category.networking": "Nettverk",
"app-store.category.social": "Sosialt",
"app-store.dependency-metadata-unavailable": "Metadata er ikke tilgjengelig",
"app-store.description": "Innstillinger for appoppdateringer",
"app-store.discover.temporarily-unavailable-description": "Utforsk kategoriene over eller søk for å finne apper",
"app-store.discover.temporarily-unavailable-title": "Utvalgt innhold er midlertidig utilgjengelig",
"app-store.discover-unavailable": "Discover er ikke tilgjengelig akkurat nå",
"app-store.featured-apps": "Fremhevede apper",
"app-store.info.support": "Brukerstøtte",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Ingen versjonsmerknader for denne versjonen.",
"app-store.search-apps": "Søk i apper",
"app-store.search.clear": "Tøm søket",
"app-store.search.no-results": "Ingen resultater",
"app-store.search.no-results-description": "Prøv et annet søk, eller bla gjennom kategoriene ovenfor.",
"app-store.search.result-count_one": "{{count}} resultat",
"app-store.search.result-count_other": "{{count}} resultater",
"app-store.search.results-for": "Resultater for",
"app-store.section.all-apps": "Alle apper",
"app-store.sort.label": "Sorter",
"app-store.sort.name": "Alfabetisk",
"app-store.sort.newest": "Nylig lagt til",
"app-store.sort.recently-updated": "Nylig oppdatert",
"app-store.status.in-progress": "Pågår",
"app-store.status.incompatible": "Ikke kompatibel",
"app-store.status.update-available": "Oppdatering tilgjengelig",
"app-store.title": "App Store",
"app-store.updates": "Oppdateringer",
"app-store.updates-shelf.names-and-more": "{{names}} med flere",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} oppdatering krever en nyere versjon av umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} oppdateringer krever en nyere versjon av umbrelOS",
"app-updates.less": "mindre",
"app-updates.more": "mer",
"app-updates.no-updates": "Alle appene er oppdatert!",
@@ -100,7 +120,6 @@
"app.installing": "Installerer",
"app.offline": "Kjører ikke",
"app.open": "Åpne",
"app.optimized-for-umbrel-home": "Optimalisert for Umbrel Home",
"app.os-update-required.confirm": "Se etter umbrelOS-oppdatering",
"app.os-update-required.description": "{{appName}} krever umbrelOS {{version}} eller nyere",
"app.os-update-required.title": "Oppdater umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Kunne ikke starte appen på nytt",
"app.toast.start-failed": "Kunne ikke starte appen",
"app.toast.stop-failed": "Kunne ikke stoppe appen",
"app.toast.update-failed": "Kunne ikke oppdatere appen",
"app.uninstall.confirm.description": "Alle data knyttet til {{app}} blir slettet permanent. Dette kan ikke angres.",
"app.uninstall.confirm.submit": "Avinstaller",
"app.uninstall.confirm.title": "Avinstallere {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Sett opp på",
"https-access-view-instructions": "Slik bruker du HTTPS",
"install-first.install-app": "Installer {{app}}",
"install-first.title": "{{app}} krever disse appene",
"install-first.title_one": "{{app}} krever denne appen",
"install-first.title_other": "{{app}} krever disse appene",
"install-your-first-app": "Installer din første app",
"language": "Språk",
"language-description": "Foretrukket umbrelOS-språk",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, netwerkinstellingen en meer",
"app-not-found": "App niet gevonden: {{app}}",
"app-only-over-tor": "{{app}} kan alleen via Tor worden gebruikt. Open deze app door je Umbrel in een Tor-browser te benaderen via je URL voor externe toegang (Instellingen > Geavanceerde instellingen > Tor-toegang op afstand).",
"app-page.section.about": "Over",
"app-page.section.about": "Over deze app",
"app-page.section.credentials.title": "Standaard inloggegevens",
"app-page.section.dependencies.n-alternatives": "Zie {{count}} alternatieven",
"app-page.section.info.compatibility": "Compatibiliteit",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Publiek",
"app-page.section.info.submitted-by": "Ingediend door",
"app-page.section.info.support": "Ondersteuning krijgen",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Versie",
"app-page.section.recommendations.title": "Dit vind je misschien ook leuk",
"app-page.section.release-notes.title": "Wat is nieuw",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Kon app niet delen",
"app-share-users.title": "Deel “{{name}}” met gebruikers",
"app-share-users.unshare-failed": "Kon het delen van de app niet stoppen",
"app-store.all-category-apps": "Alle apps in de categorie {{category}}",
"app-store.app-id-conflict": "Deze app-ID conflicteert met een andere appstore. Verwijder een van de conflicterende appstores om deze app te beheren.",
"app-store.ask-owner-to-install": "Vraag de eigenaar om deze app te installeren",
"app-store.ask-owner-to-update": "Vraag de eigenaar om deze app bij te werken",
"app-store.browse-category-apps": "Blader door {{category}} apps",
"app-store.category.ai": "AI",
"app-store.category.all": "Alle apps",
@@ -77,15 +79,33 @@
"app-store.category.media": "Media",
"app-store.category.networking": "Netwerken",
"app-store.category.social": "Sociaal",
"app-store.dependency-metadata-unavailable": "Metagegevens niet beschikbaar",
"app-store.description": "Je app-update instellingen",
"app-store.discover.temporarily-unavailable-description": "Blader door de categorieën hierboven of zoek om apps te vinden",
"app-store.discover.temporarily-unavailable-title": "Uitgelichte inhoud tijdelijk niet beschikbaar",
"app-store.discover-unavailable": "Ontdekken is momenteel niet beschikbaar",
"app-store.featured-apps": "Uitgelichte apps",
"app-store.info.support": "Ondersteuning",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Geen releaseopmerkingen voor deze versie.",
"app-store.search-apps": "Apps zoeken",
"app-store.search.clear": "Zoekopdracht wissen",
"app-store.search.no-results": "Geen resultaten",
"app-store.search.no-results-description": "Probeer een andere zoekopdracht of bekijk de categorieën hierboven.",
"app-store.search.result-count_one": "{{count}} resultaat",
"app-store.search.result-count_other": "{{count}} resultaten",
"app-store.search.results-for": "Resultaten voor",
"app-store.section.all-apps": "Alle apps",
"app-store.sort.label": "Sorteren",
"app-store.sort.name": "Alfabetisch",
"app-store.sort.newest": "Nieuw toegevoegd",
"app-store.sort.recently-updated": "Onlangs bijgewerkt",
"app-store.status.in-progress": "Bezig",
"app-store.status.incompatible": "Niet compatibel",
"app-store.status.update-available": "Update beschikbaar",
"app-store.title": "App Store",
"app-store.updates": "Updates",
"app-store.updates-shelf.names-and-more": "{{names}} en meer",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} update vereist een nieuwere versie van umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} updates vereisen een nieuwere versie van umbrelOS",
"app-updates.less": "minder",
"app-updates.more": "meer",
"app-updates.no-updates": "Alle apps zijn up-to-date!",
@@ -100,7 +120,6 @@
"app.installing": "Installeren",
"app.offline": "Niet actief",
"app.open": "Open",
"app.optimized-for-umbrel-home": "Geoptimaliseerd voor Umbrel Home",
"app.os-update-required.confirm": "Controleer op umbrelOS-update",
"app.os-update-required.description": "{{appName}} vereist umbrelOS {{version}} of later",
"app.os-update-required.title": "Update umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Kon app niet opnieuw starten",
"app.toast.start-failed": "Kon app niet starten",
"app.toast.stop-failed": "Kon app niet stoppen",
"app.toast.update-failed": "Bijwerken van app mislukt",
"app.uninstall.confirm.description": "Alle gegevens geassocieerd met {{app}} worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.",
"app.uninstall.confirm.submit": "Deïnstalleren",
"app.uninstall.confirm.title": "{{app}} deïnstalleren?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Instellen op",
"https-access-view-instructions": "HTTPS gebruiken",
"install-first.install-app": "Installeer {{app}}",
"install-first.title": "{{app}} vereist deze apps",
"install-first.title_one": "{{app}} vereist deze app",
"install-first.title_other": "{{app}} vereist deze apps",
"install-your-first-app": "Installeer je eerste app",
"language": "Taal",
"language-description": "Je voorkeurstaal voor umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, ustawienia sieci i nie tylko",
"app-not-found": "Nie znaleziono aplikacji: {{app}}",
"app-only-over-tor": "Z aplikacji {{app}} można korzystać tylko przez Tor. Otwórz swój Umbrel w przeglądarce Tor pod adresem zdalnego dostępu (Ustawienia > Ustawienia zaawansowane > Zdalny dostęp przez Tor), aby otworzyć tę aplikację.",
"app-page.section.about": "O aplikacji",
"app-page.section.about": "O tej aplikacji",
"app-page.section.credentials.title": "Domyślne dane logowania",
"app-page.section.dependencies.n-alternatives": "Zobacz {{count}} alternatyw",
"app-page.section.info.compatibility": "Zgodność",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Publiczny",
"app-page.section.info.submitted-by": "Przesłane przez",
"app-page.section.info.support": "Uzyskaj pomoc",
"app-page.section.info.title": "Informacje",
"app-page.section.info.version": "Wersja",
"app-page.section.recommendations.title": "Może ci się też spodobać",
"app-page.section.release-notes.title": "Co nowego",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Nie udało się udostępnić aplikacji",
"app-share-users.title": "Udostępnij „{{name}}” użytkownikom",
"app-share-users.unshare-failed": "Nie udało się wyłączyć udostępniania aplikacji",
"app-store.all-category-apps": "Wszystkie aplikacje w kategorii {{category}}",
"app-store.app-id-conflict": "Ten identyfikator aplikacji powoduje konflikt z innym sklepem z aplikacjami. Usuń jeden ze sklepów powodujących konflikt, aby zarządzać tą aplikacją.",
"app-store.ask-owner-to-install": "Poproś właściciela o zainstalowanie tej aplikacji",
"app-store.ask-owner-to-update": "Poproś właściciela o zaktualizowanie tej aplikacji",
"app-store.browse-category-apps": "Przeglądaj aplikacje z kategorii {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Wszystkie aplikacje",
@@ -77,15 +79,33 @@
"app-store.category.media": "Media",
"app-store.category.networking": "Sieć",
"app-store.category.social": "Społecznościowe",
"app-store.dependency-metadata-unavailable": "Metadane niedostępne",
"app-store.description": "Ustawienia aktualizacji aplikacji",
"app-store.discover.temporarily-unavailable-description": "Przeglądaj kategorie powyżej lub użyj wyszukiwarki, aby znaleźć aplikacje",
"app-store.discover.temporarily-unavailable-title": "Polecane treści są chwilowo niedostępne",
"app-store.discover-unavailable": "Discover jest teraz niedostępne",
"app-store.featured-apps": "Polecane aplikacje",
"app-store.info.support": "Pomoc techniczna",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Brak informacji o zmianach w tej wersji.",
"app-store.search-apps": "Szukaj aplikacji",
"app-store.search.clear": "Wyczyść wyszukiwanie",
"app-store.search.no-results": "Brak wyników",
"app-store.search.no-results-description": "Spróbuj wyszukać inaczej lub przejrzyj powyższe kategorie.",
"app-store.search.result-count_one": "{{count}} wynik",
"app-store.search.result-count_other": "Liczba wyników: {{count}}",
"app-store.search.results-for": "Wyniki dla",
"app-store.section.all-apps": "Wszystkie aplikacje",
"app-store.sort.label": "Sortuj",
"app-store.sort.name": "Alfabetycznie",
"app-store.sort.newest": "Nowo dodane",
"app-store.sort.recently-updated": "Ostatnio zaktualizowane",
"app-store.status.in-progress": "W toku",
"app-store.status.incompatible": "Niezgodna",
"app-store.status.update-available": "Dostępna aktualizacja",
"app-store.title": "App Store",
"app-store.updates": "Aktualizacje",
"app-store.updates-shelf.names-and-more": "{{names}} i więcej",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} aktualizacja wymaga nowszej wersji umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "Aktualizacje wymagające nowszej wersji umbrelOS: {{count}}",
"app-updates.less": "mniej",
"app-updates.more": "więcej",
"app-updates.no-updates": "Wszystkie aplikacje są aktualne!",
@@ -100,7 +120,6 @@
"app.installing": "Instalowanie",
"app.offline": "Nie działa",
"app.open": "Otwórz",
"app.optimized-for-umbrel-home": "Zoptymalizowana pod Umbrel Home",
"app.os-update-required.confirm": "Sprawdź aktualizację umbrelOS",
"app.os-update-required.description": "{{appName}} wymaga umbrelOS {{version}} lub nowszego",
"app.os-update-required.title": "Zaktualizuj umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Nie udało się ponownie uruchomić aplikacji",
"app.toast.start-failed": "Nie udało się uruchomić aplikacji",
"app.toast.stop-failed": "Nie udało się zatrzymać aplikacji",
"app.toast.update-failed": "Nie udało się zaktualizować aplikacji",
"app.uninstall.confirm.description": "Wszystkie dane powiązane z {{app}} zostaną trwale usunięte. Tej czynności nie można cofnąć.",
"app.uninstall.confirm.submit": "Odinstaluj",
"app.uninstall.confirm.title": "Odinstalować {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Skonfiguruj na",
"https-access-view-instructions": "Jak używać HTTPS",
"install-first.install-app": "Zainstaluj {{app}}",
"install-first.title": "{{app}} wymaga tych aplikacji",
"install-first.title_one": "{{app}} wymaga tej aplikacji",
"install-first.title_other": "{{app}} wymaga tych aplikacji",
"install-your-first-app": "Zainstaluj swoją pierwszą aplikację",
"language": "Język",
"language-description": "Preferowany język umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, configurações de rede e muito mais",
"app-not-found": "App não encontrado: {{app}}",
"app-only-over-tor": "{{app}} só pode ser usado via Tor. Acesse seu Umbrel em um navegador Tor usando sua URL de acesso remoto (Ajustes > Ajustes avançados > Acesso remoto via Tor) para abrir este app.",
"app-page.section.about": "Sobre",
"app-page.section.about": "Sobre este aplicativo",
"app-page.section.credentials.title": "Credenciais padrão",
"app-page.section.dependencies.n-alternatives": "Ver {{count}} alternativas",
"app-page.section.info.compatibility": "Compatibilidade",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Público",
"app-page.section.info.submitted-by": "Enviado por",
"app-page.section.info.support": "Obter suporte",
"app-page.section.info.title": "Informações",
"app-page.section.info.version": "Versão",
"app-page.section.recommendations.title": "Você também pode gostar",
"app-page.section.release-notes.title": "Novidades",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Não foi possível compartilhar o app",
"app-share-users.title": "Compartilhar “{{name}}” com usuários",
"app-share-users.unshare-failed": "Não foi possível cancelar o compartilhamento do app",
"app-store.all-category-apps": "Todos os aplicativos de {{category}}",
"app-store.app-id-conflict": "O ID deste aplicativo entra em conflito com outra App Store. Remova uma das lojas em conflito para gerenciar este aplicativo.",
"app-store.ask-owner-to-install": "Peça ao proprietário para instalar este aplicativo",
"app-store.ask-owner-to-update": "Peça ao proprietário para atualizar este aplicativo",
"app-store.browse-category-apps": "Explorar apps de {{category}}",
"app-store.category.ai": "IA",
"app-store.category.all": "Todos os apps",
@@ -77,15 +79,33 @@
"app-store.category.media": "Mídia",
"app-store.category.networking": "Rede",
"app-store.category.social": "Social",
"app-store.dependency-metadata-unavailable": "Metadados indisponíveis",
"app-store.description": "Configurações de atualização de aplicativos",
"app-store.discover.temporarily-unavailable-description": "Explore as categorias acima ou pesquise para encontrar apps",
"app-store.discover.temporarily-unavailable-title": "Conteúdo em destaque temporariamente indisponível",
"app-store.discover-unavailable": "Discover não está disponível no momento",
"app-store.featured-apps": "Aplicativos em destaque",
"app-store.info.support": "Suporte",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Não há notas de versão para esta versão.",
"app-store.search-apps": "Pesquisar apps",
"app-store.search.clear": "Limpar busca",
"app-store.search.no-results": "Nenhum resultado",
"app-store.search.no-results-description": "Tente outra busca ou navegue pelas categorias acima.",
"app-store.search.result-count_one": "{{count}} resultado",
"app-store.search.result-count_other": "{{count}} resultados",
"app-store.search.results-for": "Resultados para",
"app-store.section.all-apps": "Todos os aplicativos",
"app-store.sort.label": "Ordenar",
"app-store.sort.name": "Alfabeticamente",
"app-store.sort.newest": "Adicionados recentemente",
"app-store.sort.recently-updated": "Atualizados recentemente",
"app-store.status.in-progress": "Em andamento",
"app-store.status.incompatible": "Incompatível",
"app-store.status.update-available": "Atualização disponível",
"app-store.title": "App Store",
"app-store.updates": "Atualizações",
"app-store.updates-shelf.names-and-more": "{{names}} e mais",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} atualização requer uma versão mais recente do umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} atualizações requerem uma versão mais recente do umbrelOS",
"app-updates.less": "menos",
"app-updates.more": "mais",
"app-updates.no-updates": "Todos os apps estão atualizados!",
@@ -100,7 +120,6 @@
"app.installing": "Instalando",
"app.offline": "Não está em execução",
"app.open": "Abrir",
"app.optimized-for-umbrel-home": "Otimizado para Umbrel Home",
"app.os-update-required.confirm": "Verificar atualização do umbrelOS",
"app.os-update-required.description": "{{appName}} requer umbrelOS {{version}} ou posterior",
"app.os-update-required.title": "Atualizar umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Não foi possível reiniciar o aplicativo",
"app.toast.start-failed": "Não foi possível iniciar o aplicativo",
"app.toast.stop-failed": "Não foi possível parar o aplicativo",
"app.toast.update-failed": "Falha ao atualizar o aplicativo",
"app.uninstall.confirm.description": "Todos os dados associados a {{app}} serão apagados permanentemente. Esta ação não pode ser desfeita.",
"app.uninstall.confirm.submit": "Desinstalar",
"app.uninstall.confirm.title": "Desinstalar {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Configurar em",
"https-access-view-instructions": "Como usar HTTPS",
"install-first.install-app": "Instalar {{app}}",
"install-first.title": "{{app}} requer estes apps",
"install-first.title_one": "{{app}} requer este aplicativo",
"install-first.title_other": "{{app}} requer estes aplicativos",
"install-your-first-app": "Instale seu primeiro app",
"language": "Idioma",
"language-description": "Seu idioma preferido do umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, configurações de rede e mais",
"app-not-found": "Aplicativo não encontrado: {{app}}",
"app-only-over-tor": "{{app}} só pode ser usado via Tor. Por favor, acesse seu Umbrel em um navegador Tor pela sua URL de acesso remoto (Settings > Advanced settings > Remote Tor access) para abrir este aplicativo.",
"app-page.section.about": "Sobre",
"app-page.section.about": "Sobre este aplicativo",
"app-page.section.credentials.title": "Credenciais padrão",
"app-page.section.dependencies.n-alternatives": "Ver {{count}} alternativas",
"app-page.section.info.compatibility": "Compatibilidade",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Público",
"app-page.section.info.submitted-by": "Enviado por",
"app-page.section.info.support": "Obter suporte",
"app-page.section.info.title": "Informações",
"app-page.section.info.version": "Versão",
"app-page.section.recommendations.title": "Você também pode gostar",
"app-page.section.release-notes.title": "O que há de novo",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Não foi possível compartilhar o aplicativo",
"app-share-users.title": "Compartilhar “{{name}}” com usuários",
"app-share-users.unshare-failed": "Não foi possível cancelar o compartilhamento do aplicativo",
"app-store.all-category-apps": "Todos os aplicativos de {{category}}",
"app-store.app-id-conflict": "Este ID de aplicativo entra em conflito com outra loja de aplicativos. Remova uma das lojas em conflito para gerenciar este aplicativo.",
"app-store.ask-owner-to-install": "Peça ao proprietário para instalar este aplicativo",
"app-store.ask-owner-to-update": "Peça ao proprietário para atualizar este aplicativo",
"app-store.browse-category-apps": "Navegar por aplicativos {{category}}",
"app-store.category.ai": "IA",
"app-store.category.all": "Todos os aplicativos",
@@ -77,15 +79,33 @@
"app-store.category.media": "Mídia",
"app-store.category.networking": "Redes",
"app-store.category.social": "Social",
"app-store.dependency-metadata-unavailable": "Metadados indisponíveis",
"app-store.description": "Suas configurações de atualização de aplicativos",
"app-store.discover.temporarily-unavailable-description": "Navegue pelas categorias acima ou pesquise para encontrar apps",
"app-store.discover.temporarily-unavailable-title": "Conteúdo em destaque temporariamente indisponível",
"app-store.discover-unavailable": "O Discover não está disponível no momento",
"app-store.featured-apps": "Aplicativos em destaque",
"app-store.info.support": "Suporte",
"app-store.menu.community-app-stores": "Lojas de Aplicativos da Comunidade",
"app-store.release-timeline.no-notes": "Não há notas de versão para esta versão.",
"app-store.search-apps": "Pesquisar aplicativos",
"app-store.search.clear": "Limpar busca",
"app-store.search.no-results": "Nenhum resultado",
"app-store.search.no-results-description": "Tente fazer outra busca ou explore as categorias acima.",
"app-store.search.result-count_one": "{{count}} resultado",
"app-store.search.result-count_other": "{{count}} resultados",
"app-store.search.results-for": "Resultados para",
"app-store.section.all-apps": "Todos os aplicativos",
"app-store.sort.label": "Ordenar",
"app-store.sort.name": "Em ordem alfabética",
"app-store.sort.newest": "Adicionados recentemente",
"app-store.sort.recently-updated": "Atualizados recentemente",
"app-store.status.in-progress": "Em andamento",
"app-store.status.incompatible": "Incompatível",
"app-store.status.update-available": "Atualização disponível",
"app-store.title": "App Store",
"app-store.updates": "Atualizações",
"app-store.updates-shelf.names-and-more": "{{names}} e mais",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} atualização requer uma versão mais recente do umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} atualizações requerem uma versão mais recente do umbrelOS",
"app-updates.less": "menos",
"app-updates.more": "mais",
"app-updates.no-updates": "Todos os aplicativos estão atualizados!",
@@ -100,7 +120,6 @@
"app.installing": "Instalando",
"app.offline": "Não está em execução",
"app.open": "Abrir",
"app.optimized-for-umbrel-home": "Otimizado para Umbrel Home",
"app.os-update-required.confirm": "Verificar atualização do umbrelOS",
"app.os-update-required.description": "{{appName}} requer o umbrelOS {{version}} ou posterior",
"app.os-update-required.title": "Atualizar umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Não foi possível reiniciar o aplicativo",
"app.toast.start-failed": "Não foi possível iniciar o aplicativo",
"app.toast.stop-failed": "Não foi possível parar o aplicativo",
"app.toast.update-failed": "Não foi possível atualizar o aplicativo",
"app.uninstall.confirm.description": "Todos os dados associados a {{app}} serão permanentemente excluídos. Esta ação não pode ser desfeita.",
"app.uninstall.confirm.submit": "Desinstalar",
"app.uninstall.confirm.title": "Desinstalar {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Configurar em",
"https-access-view-instructions": "Como usar HTTPS",
"install-first.install-app": "Instalar {{app}}",
"install-first.title": "{{app}} requer estes aplicativos",
"install-first.title_one": "{{app}} precisa deste aplicativo",
"install-first.title_other": "{{app}} precisa destes aplicativos",
"install-your-first-app": "Instale seu primeiro aplicativo",
"language": "Idioma",
"language-description": "Seu idioma preferido do umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, setări de rețea și altele",
"app-not-found": "Aplicația nu a fost găsită: {{app}}",
"app-only-over-tor": "{{app}} poate fi folosită doar prin Tor. Te rugăm să îți accesezi Umbrel într-un browser Tor la URL-ul tău de acces de la distanță (Setări > Setări avansate > Acces Tor de la distanță) pentru a deschide această aplicație.",
"app-page.section.about": "Despre",
"app-page.section.about": "Despre această aplicație",
"app-page.section.credentials.title": "Date de autentificare implicite",
"app-page.section.dependencies.n-alternatives": "Vezi {{count}} alternative",
"app-page.section.info.compatibility": "Compatibilitate",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Public",
"app-page.section.info.submitted-by": "Trimis de",
"app-page.section.info.support": "Obține ajutor",
"app-page.section.info.title": "Informații",
"app-page.section.info.version": "Versiune",
"app-page.section.recommendations.title": "S-ar putea să-ți placă și",
"app-page.section.release-notes.title": "Noutăți",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Nu s-a putut partaja aplicația",
"app-share-users.title": "Partajează „{{name}}” cu utilizatorii",
"app-share-users.unshare-failed": "Nu s-a putut opri partajarea aplicației",
"app-store.all-category-apps": "Toate aplicațiile din categoria {{category}}",
"app-store.app-id-conflict": "ID-ul acestei aplicații intră în conflict cu un alt magazin de aplicații. Elimină unul dintre magazinele aflate în conflict pentru a gestiona această aplicație.",
"app-store.ask-owner-to-install": "Te rugăm să-i ceri proprietarului să instaleze această aplicație",
"app-store.ask-owner-to-update": "Te rugăm să-i ceri proprietarului să actualizeze această aplicație",
"app-store.browse-category-apps": "Răsfoiește aplicațiile din categoria {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Toate aplicațiile",
@@ -77,15 +79,33 @@
"app-store.category.media": "Media",
"app-store.category.networking": "Rețelistică",
"app-store.category.social": "Social",
"app-store.dependency-metadata-unavailable": "Metadate indisponibile",
"app-store.description": "Setările de actualizare a aplicațiilor",
"app-store.discover.temporarily-unavailable-description": "Răsfoiește categoriile de mai sus sau caută pentru a găsi aplicații",
"app-store.discover.temporarily-unavailable-title": "Conținutul recomandat este temporar indisponibil",
"app-store.discover-unavailable": "Discover nu este disponibil momentan",
"app-store.featured-apps": "Aplicații recomandate",
"app-store.info.support": "Asistență",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Nu există note de lansare pentru această versiune.",
"app-store.search-apps": "Caută aplicații",
"app-store.search.clear": "Șterge căutarea",
"app-store.search.no-results": "Niciun rezultat",
"app-store.search.no-results-description": "Încearcă altă căutare sau răsfoiește categoriile de mai sus.",
"app-store.search.result-count_one": "{{count}} rezultat",
"app-store.search.result-count_other": "{{count}} rezultate",
"app-store.search.results-for": "Rezultate pentru",
"app-store.section.all-apps": "Toate aplicațiile",
"app-store.sort.label": "Sortare",
"app-store.sort.name": "În ordine alfabetică",
"app-store.sort.newest": "Adăugate recent",
"app-store.sort.recently-updated": "Actualizate recent",
"app-store.status.in-progress": "În desfășurare",
"app-store.status.incompatible": "Incompatibilă",
"app-store.status.update-available": "Actualizare disponibilă",
"app-store.title": "App Store",
"app-store.updates": "Actualizări",
"app-store.updates-shelf.names-and-more": "{{names}} și altele",
"app-store.updates-shelf.os-update-required-count_one": "Actualizare ce necesită o versiune mai nouă de umbrelOS: {{count}}",
"app-store.updates-shelf.os-update-required-count_other": "Actualizări ce necesită o versiune mai nouă de umbrelOS: {{count}}",
"app-updates.less": "mai puțin",
"app-updates.more": "mai mult",
"app-updates.no-updates": "Toate aplicațiile sunt actualizate!",
@@ -100,7 +120,6 @@
"app.installing": "Se instalează",
"app.offline": "Nu rulează",
"app.open": "Deschide",
"app.optimized-for-umbrel-home": "Optimizată pentru Umbrel Home",
"app.os-update-required.confirm": "Verifică dacă există o actualizare umbrelOS",
"app.os-update-required.description": "{{appName}} necesită umbrelOS {{version}} sau o versiune mai nouă",
"app.os-update-required.title": "Actualizează umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Nu s-a putut reporni aplicația",
"app.toast.start-failed": "Nu s-a putut porni aplicația",
"app.toast.stop-failed": "Nu s-a putut opri aplicația",
"app.toast.update-failed": "Actualizarea aplicației a eșuat",
"app.uninstall.confirm.description": "Toate datele asociate cu {{app}} vor fi șterse definitiv. Această acțiune nu poate fi anulată.",
"app.uninstall.confirm.submit": "Dezinstalează",
"app.uninstall.confirm.title": "Dezinstalezi {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Configurează pe",
"https-access-view-instructions": "Cum să folosești HTTPS",
"install-first.install-app": "Instalează {{app}}",
"install-first.title": "{{app}} necesită aceste aplicații",
"install-first.title_one": "{{app}} necesită această aplicație",
"install-first.title_other": "{{app}} necesită aceste aplicații",
"install-your-first-app": "Instalează-ți prima aplicație",
"language": "Limbă",
"language-description": "Limba preferată pentru umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, настройки сети и многое другое",
"app-not-found": "Приложение не найдено: {{app}}",
"app-only-over-tor": "{{app}} можно использовать только через Tor. Чтобы открыть это приложение, зайди на свой Umbrel через браузер Tor по URL удалённого доступа (Settings > Advanced settings > Remote Tor access).",
"app-page.section.about": "О приложении",
"app-page.section.about": "Об этом приложении",
"app-page.section.credentials.title": "Стандартные учётные данные",
"app-page.section.dependencies.n-alternatives": "Посмотреть {{count}} альтернатив",
"app-page.section.info.compatibility": "Совместимость",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Публичный",
"app-page.section.info.submitted-by": "Добавил",
"app-page.section.info.support": "Получить поддержку",
"app-page.section.info.title": "Информация",
"app-page.section.info.version": "Версия",
"app-page.section.recommendations.title": "Тебе также может понравиться",
"app-page.section.release-notes.title": "Что нового",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Не удалось открыть доступ к приложению",
"app-share-users.title": "Открыть доступ к «{{name}}» для пользователей",
"app-share-users.unshare-failed": "Не удалось закрыть доступ к приложению",
"app-store.all-category-apps": "Все приложения категории «{{category}}»",
"app-store.app-id-conflict": "Идентификатор этого приложения конфликтует с идентификатором в другом магазине приложений. Удали один из конфликтующих магазинов, чтобы управлять этим приложением.",
"app-store.ask-owner-to-install": "Попроси владельца установить это приложение",
"app-store.ask-owner-to-update": "Попроси владельца обновить это приложение",
"app-store.browse-category-apps": "Просмотреть приложения категории {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Все приложения",
@@ -77,15 +79,33 @@
"app-store.category.media": "Медиа",
"app-store.category.networking": "Сеть",
"app-store.category.social": "Социальные сети",
"app-store.dependency-metadata-unavailable": "Метаданные недоступны",
"app-store.description": "Настройки обновления приложений",
"app-store.discover.temporarily-unavailable-description": "Просматривай категории выше или используй поиск, чтобы найти приложения",
"app-store.discover.temporarily-unavailable-title": "Рекомендуемый контент временно недоступен",
"app-store.discover-unavailable": "«Обзор» сейчас недоступен",
"app-store.featured-apps": "Рекомендуемые приложения",
"app-store.info.support": "Поддержка",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Для этой версии нет примечаний к выпуску.",
"app-store.search-apps": "Поиск приложений",
"app-store.search.clear": "Очистить поиск",
"app-store.search.no-results": "Ничего не найдено",
"app-store.search.no-results-description": "Попробуй изменить запрос или выбери категорию выше.",
"app-store.search.result-count_one": "{{count}} результат",
"app-store.search.result-count_other": "Результатов: {{count}}",
"app-store.search.results-for": "Результаты для",
"app-store.section.all-apps": "Все приложения",
"app-store.sort.label": "Сортировка",
"app-store.sort.name": "По алфавиту",
"app-store.sort.newest": "Недавно добавленные",
"app-store.sort.recently-updated": "Недавно обновлённые",
"app-store.status.in-progress": "В процессе",
"app-store.status.incompatible": "Несовместимо",
"app-store.status.update-available": "Доступно обновление",
"app-store.title": "App Store",
"app-store.updates": "Обновления",
"app-store.updates-shelf.names-and-more": "{{names}} и другие",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} обновление требует более новой версии umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "Для {{count}} обновлений требуется более новая версия umbrelOS",
"app-updates.less": "меньше",
"app-updates.more": "больше",
"app-updates.no-updates": "Все приложения обновлены!",
@@ -100,7 +120,6 @@
"app.installing": "Установка",
"app.offline": "Не запущено",
"app.open": "Открыть",
"app.optimized-for-umbrel-home": "Оптимизировано для Umbrel Home",
"app.os-update-required.confirm": "Проверить обновление umbrelOS",
"app.os-update-required.description": "Для {{appName}} требуется umbrelOS {{version}} или новее",
"app.os-update-required.title": "Обновить umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Не удалось перезапустить приложение",
"app.toast.start-failed": "Не удалось запустить приложение",
"app.toast.stop-failed": "Не удалось остановить приложение",
"app.toast.update-failed": "Не удалось обновить приложение",
"app.uninstall.confirm.description": "Все данные, связанные с {{app}}, будут удалены навсегда. Это действие нельзя отменить.",
"app.uninstall.confirm.submit": "Удалить",
"app.uninstall.confirm.title": "Удалить {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Настроить на",
"https-access-view-instructions": "Как использовать HTTPS",
"install-first.install-app": "Установить {{app}}",
"install-first.title": "Для {{app}} нужны эти приложения",
"install-first.title_one": "Для {{app}} требуется это приложение",
"install-first.title_other": "Для {{app}} требуются эти приложения",
"install-your-first-app": "Установи своё первое приложение",
"language": "Язык",
"language-description": "Предпочитаемый язык umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminál, umbrelOS Beta Program, nastavenia siete a ďalšie",
"app-not-found": "Aplikácia sa nenašla: {{app}}",
"app-only-over-tor": "{{app}} sa dá používať iba cez Tor. Ak chceš otvoriť túto aplikáciu, pristúp k svojmu Umbrel v prehliadači Tor na URL vzdialeného prístupu (Nastavenia > Rozšírené nastavenia > Vzdialený prístup cez Tor).",
"app-page.section.about": "O aplikácii",
"app-page.section.about": "O tejto aplikácii",
"app-page.section.credentials.title": "Predvolené prihlasovacie údaje",
"app-page.section.dependencies.n-alternatives": "Zobraziť {{count}} alternatív",
"app-page.section.info.compatibility": "Kompatibilita",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Verejný",
"app-page.section.info.submitted-by": "Pridal",
"app-page.section.info.support": "Získať podporu",
"app-page.section.info.title": "Informácie",
"app-page.section.info.version": "Verzia",
"app-page.section.recommendations.title": "Mohlo by sa ti páčiť aj",
"app-page.section.release-notes.title": "Čo je nové",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Aplikáciu sa nepodarilo zdieľať",
"app-share-users.title": "Zdieľať „{{name}}“ s používateľmi",
"app-share-users.unshare-failed": "Nepodarilo sa zrušiť zdieľanie aplikácie",
"app-store.all-category-apps": "Všetky aplikácie v kategórii {{category}}",
"app-store.app-id-conflict": "Toto ID aplikácie je v konflikte s iným obchodom s aplikáciami. Ak chceš túto aplikáciu spravovať, odstráň jeden z konfliktných obchodov.",
"app-store.ask-owner-to-install": "Požiadaj vlastníka, aby nainštaloval túto aplikáciu",
"app-store.ask-owner-to-update": "Požiadaj vlastníka o aktualizáciu tejto aplikácie",
"app-store.browse-category-apps": "Prehliadať aplikácie v kategórii {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Všetky aplikácie",
@@ -77,15 +79,33 @@
"app-store.category.media": "Médiá",
"app-store.category.networking": "Sieť",
"app-store.category.social": "Sociálne siete",
"app-store.dependency-metadata-unavailable": "Metadáta nie sú k dispozícii",
"app-store.description": "Nastavenia aktualizácií aplikácií",
"app-store.discover.temporarily-unavailable-description": "Prehliadaj kategórie vyššie alebo použi vyhľadávanie a nájdi aplikácie",
"app-store.discover.temporarily-unavailable-title": "Odporúčaný obsah je dočasne nedostupný",
"app-store.discover-unavailable": "Objavovanie momentálne nie je dostupné",
"app-store.featured-apps": "Odporúčané aplikácie",
"app-store.info.support": "Podpora",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Pre túto verziu nie sú k dispozícii poznámky k vydaniu.",
"app-store.search-apps": "Hľadať aplikácie",
"app-store.search.clear": "Vymazať vyhľadávanie",
"app-store.search.no-results": "Žiadne výsledky",
"app-store.search.no-results-description": "Skús iné vyhľadávanie alebo si prezri kategórie vyššie.",
"app-store.search.result-count_one": "{{count}} výsledok",
"app-store.search.result-count_other": "Počet výsledkov: {{count}}",
"app-store.search.results-for": "Výsledky pre",
"app-store.section.all-apps": "Všetky aplikácie",
"app-store.sort.label": "Zoradiť",
"app-store.sort.name": "Abecedne",
"app-store.sort.newest": "Najnovšie pridané",
"app-store.sort.recently-updated": "Nedávno aktualizované",
"app-store.status.in-progress": "Prebieha",
"app-store.status.incompatible": "Nekompatibilné",
"app-store.status.update-available": "Dostupná aktualizácia",
"app-store.title": "App Store",
"app-store.updates": "Aktualizácie",
"app-store.updates-shelf.names-and-more": "{{names}} a ďalšie",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} aktualizácia vyžaduje novšiu verziu umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "Aktualizácie vyžadujúce novšiu verziu umbrelOS: {{count}}",
"app-updates.less": "menej",
"app-updates.more": "viac",
"app-updates.no-updates": "Všetky aplikácie sú aktuálne!",
@@ -100,7 +120,6 @@
"app.installing": "Inštaluje sa",
"app.offline": "Nebeží",
"app.open": "Otvoriť",
"app.optimized-for-umbrel-home": "Optimalizované pre Umbrel Home",
"app.os-update-required.confirm": "Skontrolovať aktualizáciu umbrelOS",
"app.os-update-required.description": "{{appName}} vyžaduje umbrelOS {{version}} alebo novší",
"app.os-update-required.title": "Aktualizovať umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Nepodarilo sa reštartovať aplikáciu",
"app.toast.start-failed": "Nepodarilo sa spustiť aplikáciu",
"app.toast.stop-failed": "Nepodarilo sa zastaviť aplikáciu",
"app.toast.update-failed": "Aplikáciu sa nepodarilo aktualizovať",
"app.uninstall.confirm.description": "Všetky dáta spojené s aplikáciou {{app}} budú natrvalo odstránené. Túto akciu nie je možné vrátiť späť.",
"app.uninstall.confirm.submit": "Odinštalovať",
"app.uninstall.confirm.title": "Odinštalovať {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Nastaviť na",
"https-access-view-instructions": "Ako používať HTTPS",
"install-first.install-app": "Nainštalovať {{app}}",
"install-first.title": "{{app}} vyžaduje tieto aplikácie",
"install-first.title_one": "{{app}} vyžaduje túto aplikáciu",
"install-first.title_other": "{{app}} vyžaduje tieto aplikácie",
"install-your-first-app": "Nainštaluj si svoju prvú aplikáciu",
"language": "Jazyk",
"language-description": "Preferovaný jazyk umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, omrežne nastavitve in še več",
"app-not-found": "Aplikacija ni bila najdena: {{app}}",
"app-only-over-tor": "{{app}} lahko uporabljaš samo prek Tor. Za odprtje te aplikacije odpri svoj Umbrel v brskalniku Tor na svojem URL-ju za oddaljeni dostop (Nastavitve > Napredne nastavitve > Oddaljeni dostop prek Tor).",
"app-page.section.about": "O aplikaciji",
"app-page.section.about": "O tej aplikaciji",
"app-page.section.credentials.title": "Privzeti podatki za prijavo",
"app-page.section.dependencies.n-alternatives": "Prikaži {{count}} alternativ",
"app-page.section.info.compatibility": "Združljivost",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Javna",
"app-page.section.info.submitted-by": "Predložil",
"app-page.section.info.support": "Pridobi pomoč",
"app-page.section.info.title": "Informacije",
"app-page.section.info.version": "Različica",
"app-page.section.recommendations.title": "Morda ti bo všeč tudi",
"app-page.section.release-notes.title": "Kaj je novega",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Aplikacije ni bilo mogoče deliti",
"app-share-users.title": "Deli “{{name}}” z uporabniki",
"app-share-users.unshare-failed": "Skupne rabe aplikacije ni bilo mogoče ustaviti",
"app-store.all-category-apps": "Vse aplikacije v kategoriji {{category}}",
"app-store.app-id-conflict": "ID te aplikacije je v navzkrižju z drugo trgovino z aplikacijami. Odstrani eno od trgovin v navzkrižju, da lahko upravljaš to aplikacijo.",
"app-store.ask-owner-to-install": "Prosi lastnika, naj namesti to aplikacijo",
"app-store.ask-owner-to-update": "Prosi lastnika, naj posodobi to aplikacijo",
"app-store.browse-category-apps": "Prebrskaj aplikacije v kategoriji {{category}}",
"app-store.category.ai": "UI",
"app-store.category.all": "Vse aplikacije",
@@ -77,15 +79,33 @@
"app-store.category.media": "Mediji",
"app-store.category.networking": "Omrežje",
"app-store.category.social": "Družabno",
"app-store.dependency-metadata-unavailable": "Metapodatki niso na voljo",
"app-store.description": "Nastavitve posodobitev aplikacij",
"app-store.discover.temporarily-unavailable-description": "Prebrskaj zgornje kategorije ali uporabi iskanje, da najdeš aplikacije",
"app-store.discover.temporarily-unavailable-title": "Izpostavljena vsebina trenutno ni na voljo",
"app-store.discover-unavailable": "Odkrivanje trenutno ni na voljo",
"app-store.featured-apps": "Izbrane aplikacije",
"app-store.info.support": "Podpora",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Za to različico ni opomb ob izdaji.",
"app-store.search-apps": "Išči aplikacije",
"app-store.search.clear": "Počisti iskanje",
"app-store.search.no-results": "Ni rezultatov",
"app-store.search.no-results-description": "Poskusi iskati drugače ali prebrskaj zgornje kategorije.",
"app-store.search.result-count_one": "{{count}} rezultat",
"app-store.search.result-count_other": "Število rezultatov: {{count}}",
"app-store.search.results-for": "Rezultati za",
"app-store.section.all-apps": "Vse aplikacije",
"app-store.sort.label": "Razvrsti",
"app-store.sort.name": "Po abecedi",
"app-store.sort.newest": "Na novo dodane",
"app-store.sort.recently-updated": "Nedavno posodobljene",
"app-store.status.in-progress": "V teku",
"app-store.status.incompatible": "Ni združljiva",
"app-store.status.update-available": "Na voljo je posodobitev",
"app-store.title": "App Store",
"app-store.updates": "Posodobitve",
"app-store.updates-shelf.names-and-more": "{{names}} in druge",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} posodobitev zahteva novejšo različico umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "Za {{count}} posodobitev je potrebna novejša različica umbrelOS",
"app-updates.less": "manj",
"app-updates.more": "več",
"app-updates.no-updates": "Vse aplikacije so posodobljene!",
@@ -100,7 +120,6 @@
"app.installing": "Nameščanje",
"app.offline": "Ni zagnano",
"app.open": "Odpri",
"app.optimized-for-umbrel-home": "Optimizirano za Umbrel Home",
"app.os-update-required.confirm": "Preveri posodobitve za umbrelOS",
"app.os-update-required.description": "{{appName}} zahteva umbrelOS {{version}} ali novejši",
"app.os-update-required.title": "Posodobi umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Aplikacije ni bilo mogoče znova zagnati",
"app.toast.start-failed": "Aplikacije ni bilo mogoče zagnati",
"app.toast.stop-failed": "Aplikacije ni bilo mogoče ustaviti",
"app.toast.update-failed": "Posodobitev aplikacije ni uspela",
"app.uninstall.confirm.description": "Vsi podatki, povezani z aplikacijo {{app}}, bodo trajno izbrisani. Tega dejanja ni mogoče razveljaviti.",
"app.uninstall.confirm.submit": "Odstrani",
"app.uninstall.confirm.title": "Odstranim {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Nastavi na",
"https-access-view-instructions": "Kako uporabljati HTTPS",
"install-first.install-app": "Namesti {{app}}",
"install-first.title": "{{app}} zahteva te aplikacije",
"install-first.title_one": "{{app}} zahteva to aplikacijo",
"install-first.title_other": "{{app}} zahteva te aplikacije",
"install-your-first-app": "Namesti svojo prvo aplikacijo",
"language": "Jezik",
"language-description": "Želeni jezik umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Program, nätverksinställningar och mer",
"app-not-found": "Appen hittades inte: {{app}}",
"app-only-over-tor": "{{app}} kan bara användas via Tor. Öppna din Umbrel i en Tor-webbläsare på din fjärråtkomst-URL (Inställningar > Avancerade inställningar > Remote Tor access) för att öppna appen.",
"app-page.section.about": "Om",
"app-page.section.about": "Om den här appen",
"app-page.section.credentials.title": "Standardinloggning",
"app-page.section.dependencies.n-alternatives": "Se {{count}} alternativ",
"app-page.section.info.compatibility": "Kompatibilitet",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Offentlig",
"app-page.section.info.submitted-by": "Inskickad av",
"app-page.section.info.support": "Få support",
"app-page.section.info.title": "Info",
"app-page.section.info.version": "Version",
"app-page.section.recommendations.title": "Du kanske också gillar",
"app-page.section.release-notes.title": "Vad är nytt",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Kunde inte dela appen",
"app-share-users.title": "Dela “{{name}}” med användare",
"app-share-users.unshare-failed": "Kunde inte sluta dela appen",
"app-store.all-category-apps": "Alla appar i {{category}}",
"app-store.app-id-conflict": "Det här app-ID:t står i konflikt med en annan appbutik. Ta bort en av de motstridiga butikerna för att hantera appen.",
"app-store.ask-owner-to-install": "Be ägaren att installera den här appen",
"app-store.ask-owner-to-update": "Be ägaren att uppdatera den här appen",
"app-store.browse-category-apps": "Bläddra bland appar i {{category}}",
"app-store.category.ai": "AI",
"app-store.category.all": "Alla appar",
@@ -77,15 +79,33 @@
"app-store.category.media": "Media",
"app-store.category.networking": "Nätverk",
"app-store.category.social": "Socialt",
"app-store.dependency-metadata-unavailable": "Metadata är inte tillgängliga",
"app-store.description": "Dina appuppdateringsinställningar",
"app-store.discover.temporarily-unavailable-description": "Bläddra bland kategorierna ovan eller sök för att hitta appar",
"app-store.discover.temporarily-unavailable-title": "Utvalt innehåll är tillfälligt otillgängligt",
"app-store.discover-unavailable": "Upptäck är inte tillgängligt just nu",
"app-store.featured-apps": "Utvalda appar",
"app-store.info.support": "Support",
"app-store.menu.community-app-stores": "Community App Stores",
"app-store.release-timeline.no-notes": "Ingen versionsinformation för den här versionen.",
"app-store.search-apps": "Sök appar",
"app-store.search.clear": "Rensa sökning",
"app-store.search.no-results": "Inga resultat",
"app-store.search.no-results-description": "Prova en annan sökning eller bläddra bland kategorierna ovan.",
"app-store.search.result-count_one": "{{count}} resultat",
"app-store.search.result-count_other": "{{count}} resultat",
"app-store.search.results-for": "Resultat för",
"app-store.section.all-apps": "Alla appar",
"app-store.sort.label": "Sortera",
"app-store.sort.name": "Alfabetiskt",
"app-store.sort.newest": "Nyligen tillagda",
"app-store.sort.recently-updated": "Nyligen uppdaterade",
"app-store.status.in-progress": "Pågår",
"app-store.status.incompatible": "Inte kompatibel",
"app-store.status.update-available": "Uppdatering tillgänglig",
"app-store.title": "App Store",
"app-store.updates": "Uppdateringar",
"app-store.updates-shelf.names-and-more": "{{names}} och fler",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} uppdatering kräver en nyare version av umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} uppdateringar kräver en nyare version av umbrelOS",
"app-updates.less": "mindre",
"app-updates.more": "mer",
"app-updates.no-updates": "Alla appar är uppdaterade!",
@@ -100,7 +120,6 @@
"app.installing": "Installerar",
"app.offline": "Körs inte",
"app.open": "Öppna",
"app.optimized-for-umbrel-home": "Optimerad för Umbrel Home",
"app.os-update-required.confirm": "Sök efter umbrelOS-uppdatering",
"app.os-update-required.description": "{{appName}} kräver umbrelOS {{version}} eller senare",
"app.os-update-required.title": "Uppdatera umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Det gick inte att starta om appen",
"app.toast.start-failed": "Det gick inte att starta appen",
"app.toast.stop-failed": "Det gick inte att stoppa appen",
"app.toast.update-failed": "Det gick inte att uppdatera appen",
"app.uninstall.confirm.description": "All data som är kopplad till {{app}} kommer att raderas permanent. Den här åtgärden kan inte ångras.",
"app.uninstall.confirm.submit": "Avinstallera",
"app.uninstall.confirm.title": "Avinstallera {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Ställ in på",
"https-access-view-instructions": "Så använder du HTTPS",
"install-first.install-app": "Installera {{app}}",
"install-first.title": "{{app}} kräver de här apparna",
"install-first.title_one": "{{app}} kräver den här appen",
"install-first.title_other": "{{app}} kräver de här apparna",
"install-your-first-app": "Installera din första app",
"language": "Språk",
"language-description": "Ditt föredragna umbrelOS-språk",
+28 -7
View File
@@ -32,18 +32,17 @@
"advanced-settings-description": "Terminal, umbrelOS Beta Programı, ağ ayarları ve daha fazlası",
"app-not-found": "Uygulama bulunamadı: {{app}}",
"app-only-over-tor": "{{app}} sadece Tor üzerinden kullanılabilir. Bu uygulamayı açmak için lütfen uzak erişim URL'nizde (Ayarlar > Gelişmiş ayarlar > Uzaktan Tor erişimi) Tor tarayıcısı ile Umbrel'inize erişin.",
"app-page.section.about": "Hakkında",
"app-page.section.about": "Bu uygulama hakkında",
"app-page.section.credentials.title": "Varsayılan kimlik bilgileri",
"app-page.section.dependencies.n-alternatives": "{{count}} alternatiflere bak",
"app-page.section.info.compatibility": "Uyumluluk",
"app-page.section.info.compatibility-compatible": "Uyumlu",
"app-page.section.info.compatibility-not-compatible": "Uyumlu değil",
"app-page.section.info.developer": "Geliştirici",
"app-page.section.info.source-code": "Kaynak Kodu",
"app-page.section.info.source-code": "Kaynak kodu",
"app-page.section.info.source-code.public": "Herkese açık",
"app-page.section.info.submitted-by": "Gönderen",
"app-page.section.info.support": "Destek al",
"app-page.section.info.title": "Bilgi",
"app-page.section.info.version": "Sürüm",
"app-page.section.recommendations.title": "Bunları da beğenebilirsiniz",
"app-page.section.release-notes.title": "Yenilikler",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Uygulama paylaşılamadı",
"app-share-users.title": "“{{name}}” uygulamasını kullanıcılarla paylaş",
"app-share-users.unshare-failed": "Uygulama paylaşımı durdurulamadı",
"app-store.all-category-apps": "Tüm {{category}} uygulamaları",
"app-store.app-id-conflict": "Bu uygulama kimliği başka bir App Store ile çakışıyor. Bu uygulamayı yönetmek için çakışan App Store'lardan birini kaldırın.",
"app-store.ask-owner-to-install": "Lütfen bu uygulamayı yüklemesi için sahibinden yardım isteyin",
"app-store.ask-owner-to-update": "Lütfen sahibinden bu uygulamayı güncellemesini isteyin",
"app-store.browse-category-apps": "{{category}} uygulamalarına göz at",
"app-store.category.ai": "Yapay Zeka",
"app-store.category.all": "Tüm uygulamalar",
@@ -77,15 +79,33 @@
"app-store.category.media": "Medya",
"app-store.category.networking": "Ağ",
"app-store.category.social": "Sosyal",
"app-store.dependency-metadata-unavailable": "Meta veriler kullanılamıyor",
"app-store.description": "Uygulama güncelleme ayarlarınız",
"app-store.discover.temporarily-unavailable-description": "Uygulama bulmak için yukarıdaki kategorilere göz atabilir veya arama yapabilirsiniz",
"app-store.discover.temporarily-unavailable-title": "Öne çıkan içerik şu anda kullanılamıyor",
"app-store.discover-unavailable": "Keşfet şu anda kullanılamıyor",
"app-store.featured-apps": "Öne çıkan uygulamalar",
"app-store.info.support": "Destek",
"app-store.menu.community-app-stores": "Topluluk Uygulama Mağazaları",
"app-store.release-timeline.no-notes": "Bu sürüm için sürüm notu yok.",
"app-store.search-apps": "Uygulama ara",
"app-store.search.clear": "Aramayı temizle",
"app-store.search.no-results": "Sonuç yok",
"app-store.search.no-results-description": "Farklı bir arama yapın veya yukarıdaki kategorilere göz atın.",
"app-store.search.result-count_one": "{{count}} sonuç",
"app-store.search.result-count_other": "{{count}} sonuç",
"app-store.search.results-for": "Sonuçlar",
"app-store.section.all-apps": "Tüm uygulamalar",
"app-store.sort.label": "Sırala",
"app-store.sort.name": "Alfabetik",
"app-store.sort.newest": "Yeni eklenenler",
"app-store.sort.recently-updated": "Yakın zamanda güncellenenler",
"app-store.status.in-progress": "Devam ediyor",
"app-store.status.incompatible": "Uyumlu değil",
"app-store.status.update-available": "Güncelleme mevcut",
"app-store.title": "Uygulama Mağazası",
"app-store.updates": "Güncellemeler",
"app-store.updates-shelf.names-and-more": "{{names}} ve daha fazlası",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} güncelleme için daha yeni bir umbrelOS sürümü gerekiyor",
"app-store.updates-shelf.os-update-required-count_other": "{{count}} güncelleme için daha yeni bir umbrelOS sürümü gerekiyor",
"app-updates.less": "daha az",
"app-updates.more": "daha fazla",
"app-updates.no-updates": "Tüm uygulamalar güncel!",
@@ -100,7 +120,6 @@
"app.installing": "Yükleniyor",
"app.offline": "Çalışmıyor",
"app.open": "Aç",
"app.optimized-for-umbrel-home": "Umbrel Home için optimize edilmiş",
"app.os-update-required.confirm": "umbrelOS güncellemelerini kontrol et",
"app.os-update-required.description": "{{appName}} için umbrelOS {{version}} veya daha yenisi gerekiyor",
"app.os-update-required.title": "umbrelOS'u güncelle",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Uygulama yeniden başlatılamadı",
"app.toast.start-failed": "Uygulama başlatılamadı",
"app.toast.stop-failed": "Uygulama durdurulamadı",
"app.toast.update-failed": "Uygulama güncellenemedi",
"app.uninstall.confirm.description": "{{app}} ile ilgili tüm veriler kalıcı olarak silinecek. Bu işlem geri alınamaz.",
"app.uninstall.confirm.submit": "Kaldır",
"app.uninstall.confirm.title": "{{app}} kaldırılacak mı?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Şurada ayarla",
"https-access-view-instructions": "HTTPS nasıl kullanılır",
"install-first.install-app": "{{app}} yükle",
"install-first.title": "{{app}} bu uygulamalara ihtiyaç duyuyor",
"install-first.title_one": "{{app}} bu uygulamayı gerektiriyor",
"install-first.title_other": "{{app}} bu uygulamaları gerektiriyor",
"install-your-first-app": "İlk uygulamanızı yükleyin",
"language": "Dil",
"language-description": "Tercih ettiğiniz umbrelOS dili",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Термінал, Beta-програма umbrelOS, мережеві налаштування та інше",
"app-not-found": "Програму не знайдено: {{app}}",
"app-only-over-tor": "{{app}} можна використовувати лише через Tor. Будь ласка, відкрийте свій Umbrel у браузері Tor за URL-адресою віддаленого доступу (Налаштування > Розширені налаштування > Віддалений Tor-доступ), щоб відкрити цей додаток.",
"app-page.section.about": "Про",
"app-page.section.about": "Про цю програму",
"app-page.section.credentials.title": "Стандартні облікові дані",
"app-page.section.dependencies.n-alternatives": "Переглянути {{count}} альтернатив",
"app-page.section.info.compatibility": "Сумісність",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "Публічний",
"app-page.section.info.submitted-by": "Подано",
"app-page.section.info.support": "Отримати підтримку",
"app-page.section.info.title": "Інформація",
"app-page.section.info.version": "Версія",
"app-page.section.recommendations.title": "Вам також може сподобатися",
"app-page.section.release-notes.title": "Що нового",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "Не вдалося поділитися додатком",
"app-share-users.title": "Поділитися “{{name}}” з користувачами",
"app-share-users.unshare-failed": "Не вдалося скасувати спільний доступ до додатка",
"app-store.all-category-apps": "Усі програми категорії {{category}}",
"app-store.app-id-conflict": "ID цієї програми конфліктує з іншим App Store. Видаліть один із конфліктних App Store, щоб керувати цією програмою.",
"app-store.ask-owner-to-install": "Попросіть власника встановити цей додаток",
"app-store.ask-owner-to-update": "Попросіть власника оновити цю програму",
"app-store.browse-category-apps": "Перегляд програм у категорії {{category}}",
"app-store.category.ai": "Штучний інтелект",
"app-store.category.all": "Всі програми",
@@ -77,15 +79,33 @@
"app-store.category.media": "Медіа",
"app-store.category.networking": "Мережі",
"app-store.category.social": "Соціальні мережі",
"app-store.dependency-metadata-unavailable": "Метадані недоступні",
"app-store.description": "Налаштування оновлення ваших програм",
"app-store.discover.temporarily-unavailable-description": "Перегляньте категорії вище або скористайтеся пошуком, щоб знайти застосунки",
"app-store.discover.temporarily-unavailable-title": "Рекомендований контент тимчасово недоступний",
"app-store.discover-unavailable": "Огляд зараз недоступний",
"app-store.featured-apps": "Рекомендовані програми",
"app-store.info.support": "Підтримка",
"app-store.menu.community-app-stores": "Спільнота магазинів програм",
"app-store.release-timeline.no-notes": "Для цієї версії немає приміток до випуску.",
"app-store.search-apps": "Пошук програм",
"app-store.search.clear": "Очистити пошук",
"app-store.search.no-results": "Немає результатів",
"app-store.search.no-results-description": "Спробуйте інший пошуковий запит або перегляньте категорії вище.",
"app-store.search.result-count_one": "Результат: {{count}}",
"app-store.search.result-count_other": "Результатів: {{count}}",
"app-store.search.results-for": "Результати для",
"app-store.section.all-apps": "Усі програми",
"app-store.sort.label": "Сортування",
"app-store.sort.name": "За абеткою",
"app-store.sort.newest": "Нещодавно додані",
"app-store.sort.recently-updated": "Нещодавно оновлені",
"app-store.status.in-progress": "Виконується",
"app-store.status.incompatible": "Несумісна",
"app-store.status.update-available": "Доступне оновлення",
"app-store.title": "App Store",
"app-store.updates": "Оновлення",
"app-store.updates-shelf.names-and-more": "{{names}} та інші",
"app-store.updates-shelf.os-update-required-count_one": "Оновлення: {{count}}. Потрібна новіша версія umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "Потрібна новіша версія umbrelOS. Оновлень: {{count}}",
"app-updates.less": "менше",
"app-updates.more": "більше",
"app-updates.no-updates": "Всі програми оновлені!",
@@ -100,7 +120,6 @@
"app.installing": "Встановлення",
"app.offline": "Не працює",
"app.open": "Відкрити",
"app.optimized-for-umbrel-home": "Оптимізовано для Umbrel Home",
"app.os-update-required.confirm": "Перевірити оновлення umbrelOS",
"app.os-update-required.description": "{{appName}} потребує umbrelOS версії {{version}} або новішої",
"app.os-update-required.title": "Оновити umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "Не вдалося перезапустити додаток",
"app.toast.start-failed": "Не вдалося запустити додаток",
"app.toast.stop-failed": "Не вдалося зупинити додаток",
"app.toast.update-failed": "Не вдалося оновити програму",
"app.uninstall.confirm.description": "Всі дані, пов'язані з {{app}}, будуть безповоротно видалені. Ця дія не може бути скасована.",
"app.uninstall.confirm.submit": "Видалити",
"app.uninstall.confirm.title": "Видалити {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "Налаштувати на",
"https-access-view-instructions": "Як користуватися HTTPS",
"install-first.install-app": "Встановити {{app}}",
"install-first.title": "{{app}} вимагає ці додатки",
"install-first.title_one": "Для {{app}} потрібна ця програма",
"install-first.title_other": "Для {{app}} потрібні ці програми",
"install-your-first-app": "Встановіть свою першу програму",
"language": "Мова",
"language-description": "Ваша бажана мова umbrelOS",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal、umbrelOS Beta Program、網路設定等",
"app-not-found": "找不到應用程式:{{app}}",
"app-only-over-tor": "{{app}} 只能透過 Tor 使用。請在 Tor 瀏覽器中使用你的遠端存取 URL 開啟 Umbrel(設定 > 進階設定 > 遠端 Tor 存取),以開啟此應用程式。",
"app-page.section.about": "關於",
"app-page.section.about": "關於此應用程式",
"app-page.section.credentials.title": "預設登入資訊",
"app-page.section.dependencies.n-alternatives": "查看 {{count}} 個替代方案",
"app-page.section.info.compatibility": "相容性",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "公開",
"app-page.section.info.submitted-by": "提交者",
"app-page.section.info.support": "取得支援",
"app-page.section.info.title": "資訊",
"app-page.section.info.version": "版本",
"app-page.section.recommendations.title": "你可能也會喜歡",
"app-page.section.release-notes.title": "最新內容",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "無法共享應用程式",
"app-share-users.title": "與使用者共享「{{name}}」",
"app-share-users.unshare-failed": "無法停止共享應用程式",
"app-store.all-category-apps": "所有 {{category}} 應用程式",
"app-store.app-id-conflict": "此應用程式 ID 與另一個應用程式商店衝突。請移除其中一個衝突的商店,以管理此應用程式。",
"app-store.ask-owner-to-install": "請聯絡擁有者安裝此應用程式",
"app-store.ask-owner-to-update": "請要求擁有者更新此應用程式",
"app-store.browse-category-apps": "瀏覽 {{category}} 應用程式",
"app-store.category.ai": "AI",
"app-store.category.all": "所有應用程式",
@@ -77,15 +79,33 @@
"app-store.category.media": "媒體",
"app-store.category.networking": "網路",
"app-store.category.social": "社交",
"app-store.dependency-metadata-unavailable": "無法取得中繼資料",
"app-store.description": "你的應用程式更新設定",
"app-store.discover.temporarily-unavailable-description": "瀏覽上方分類或使用搜尋來尋找應用程式",
"app-store.discover.temporarily-unavailable-title": "精選內容暫時無法使用",
"app-store.discover-unavailable": "「探索」目前無法使用",
"app-store.featured-apps": "精選應用程式",
"app-store.info.support": "支援",
"app-store.menu.community-app-stores": "社群 App Stores",
"app-store.release-timeline.no-notes": "此版本沒有版本說明。",
"app-store.search-apps": "搜尋應用程式",
"app-store.search.clear": "清除搜尋",
"app-store.search.no-results": "沒有結果",
"app-store.search.no-results-description": "請嘗試其他搜尋內容,或瀏覽上方的類別。",
"app-store.search.result-count_one": "{{count}} 個結果",
"app-store.search.result-count_other": "共 {{count}} 個結果",
"app-store.search.results-for": "搜尋結果:",
"app-store.section.all-apps": "所有應用程式",
"app-store.sort.label": "排序",
"app-store.sort.name": "按字母排序",
"app-store.sort.newest": "最新加入",
"app-store.sort.recently-updated": "最近更新",
"app-store.status.in-progress": "進行中",
"app-store.status.incompatible": "不相容",
"app-store.status.update-available": "有可用更新",
"app-store.title": "App Store",
"app-store.updates": "更新",
"app-store.updates-shelf.names-and-more": "{{names}},還有更多",
"app-store.updates-shelf.os-update-required-count_one": "{{count}} 個更新需要較新版本的 umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "有 {{count}} 個更新需要較新版本的 umbrelOS",
"app-updates.less": "較少",
"app-updates.more": "更多",
"app-updates.no-updates": "所有應用程式都是最新版本!",
@@ -100,7 +120,6 @@
"app.installing": "安裝中",
"app.offline": "未執行",
"app.open": "開啟",
"app.optimized-for-umbrel-home": "已針對 Umbrel Home 最佳化",
"app.os-update-required.confirm": "檢查 umbrelOS 更新",
"app.os-update-required.description": "{{appName}} 需要 umbrelOS {{version}} 或以上版本",
"app.os-update-required.title": "更新 umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "無法重新啟動應用程式",
"app.toast.start-failed": "無法啟動應用程式",
"app.toast.stop-failed": "無法停止應用程式",
"app.toast.update-failed": "無法更新應用程式",
"app.uninstall.confirm.description": "與 {{app}} 相關的所有資料都會被永久刪除。此操作無法復原。",
"app.uninstall.confirm.submit": "解除安裝",
"app.uninstall.confirm.title": "要解除安裝 {{app}} 嗎?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "設定平台",
"https-access-view-instructions": "如何使用 HTTPS",
"install-first.install-app": "安裝 {{app}}",
"install-first.title": "{{app}} 需要這些應用程式",
"install-first.title_one": "{{app}} 需要此應用程式",
"install-first.title_other": "{{app}} 需要這些應用程式",
"install-your-first-app": "安裝你的第一個應用程式",
"language": "語言",
"language-description": "你偏好的 umbrelOS 語言",
+27 -6
View File
@@ -32,7 +32,7 @@
"advanced-settings-description": "Terminal、umbrelOS Beta Program、网络设置等",
"app-not-found": "未找到应用:{{app}}",
"app-only-over-tor": "{{app}} 只能通过 Tor 使用。请在 Tor 浏览器中通过你的远程访问 URL 访问你的 Umbrel(设置 > 高级设置 > 远程 Tor 访问)来打开这个应用。",
"app-page.section.about": "关于",
"app-page.section.about": "关于此应用",
"app-page.section.credentials.title": "默认凭据",
"app-page.section.dependencies.n-alternatives": "查看 {{count}} 个替代方案",
"app-page.section.info.compatibility": "兼容性",
@@ -43,7 +43,6 @@
"app-page.section.info.source-code.public": "公开",
"app-page.section.info.submitted-by": "提交者",
"app-page.section.info.support": "获取支持",
"app-page.section.info.title": "信息",
"app-page.section.info.version": "版本",
"app-page.section.recommendations.title": "你可能也喜欢",
"app-page.section.release-notes.title": "新内容",
@@ -63,7 +62,10 @@
"app-share-users.share-failed": "无法共享应用",
"app-share-users.title": "与用户共享“{{name}}”",
"app-share-users.unshare-failed": "无法取消共享应用",
"app-store.all-category-apps": "所有{{category}}应用",
"app-store.app-id-conflict": "此应用 ID 与另一个应用商店冲突。请移除其中一个冲突的商店,以管理此应用。",
"app-store.ask-owner-to-install": "请让所有者安装此应用",
"app-store.ask-owner-to-update": "请让所有者更新此应用",
"app-store.browse-category-apps": "浏览 {{category}} 应用",
"app-store.category.ai": "AI",
"app-store.category.all": "所有应用",
@@ -77,15 +79,33 @@
"app-store.category.media": "媒体",
"app-store.category.networking": "网络",
"app-store.category.social": "社交",
"app-store.dependency-metadata-unavailable": "元数据不可用",
"app-store.description": "你的应用更新设置",
"app-store.discover.temporarily-unavailable-description": "浏览上方分类或搜索来查找应用",
"app-store.discover.temporarily-unavailable-title": "精选内容暂时不可用",
"app-store.discover-unavailable": "“发现”目前不可用",
"app-store.featured-apps": "精选应用",
"app-store.info.support": "支持",
"app-store.menu.community-app-stores": "社区 App Store",
"app-store.release-timeline.no-notes": "此版本没有发布说明。",
"app-store.search-apps": "搜索应用",
"app-store.search.clear": "清除搜索",
"app-store.search.no-results": "无结果",
"app-store.search.no-results-description": "请尝试其他搜索词,或浏览上方的分类。",
"app-store.search.result-count_one": "{{count}} 个结果",
"app-store.search.result-count_other": "共 {{count}} 个结果",
"app-store.search.results-for": "搜索结果:",
"app-store.section.all-apps": "所有应用",
"app-store.sort.label": "排序",
"app-store.sort.name": "按字母顺序",
"app-store.sort.newest": "最新添加",
"app-store.sort.recently-updated": "最近更新",
"app-store.status.in-progress": "进行中",
"app-store.status.incompatible": "不兼容",
"app-store.status.update-available": "有可用更新",
"app-store.title": "App Store",
"app-store.updates": "更新",
"app-store.updates-shelf.names-and-more": "{{names}} 等",
"app-store.updates-shelf.os-update-required-count_one": "有 {{count}} 个更新需要更高版本的 umbrelOS",
"app-store.updates-shelf.os-update-required-count_other": "共有 {{count}} 个更新需要更高版本的 umbrelOS",
"app-updates.less": "收起",
"app-updates.more": "更多",
"app-updates.no-updates": "所有应用都是最新的!",
@@ -100,7 +120,6 @@
"app.installing": "安装中",
"app.offline": "未运行",
"app.open": "打开",
"app.optimized-for-umbrel-home": "已针对 Umbrel Home 优化",
"app.os-update-required.confirm": "检查 umbrelOS 更新",
"app.os-update-required.description": "{{appName}} 需要 umbrelOS {{version}} 或更高版本",
"app.os-update-required.title": "更新 umbrelOS",
@@ -111,6 +130,7 @@
"app.toast.restart-failed": "重新启动应用失败",
"app.toast.start-failed": "启动应用失败",
"app.toast.stop-failed": "停止应用失败",
"app.toast.update-failed": "应用更新失败",
"app.uninstall.confirm.description": "与 {{app}} 相关的所有数据都将被永久删除。此操作无法撤销。",
"app.uninstall.confirm.submit": "卸载",
"app.uninstall.confirm.title": "卸载 {{app}}?",
@@ -1012,7 +1032,8 @@
"https-access-setup-platform-label": "在以下平台上设置",
"https-access-view-instructions": "如何使用 HTTPS",
"install-first.install-app": "安装 {{app}}",
"install-first.title": "{{app}} 需要这些应用",
"install-first.title_one": "{{app}} 需要此应用",
"install-first.title_other": "{{app}} 需要这些应用",
"install-your-first-app": "安装你的第一个应用",
"language": "语言",
"language-description": "你首选的 umbrelOS 语言",
+2 -19
View File
@@ -1,5 +1,4 @@
import {useCommandState} from 'cmdk'
import {TFunction} from 'i18next'
import {
ComponentPropsWithoutRef,
createContext,
@@ -32,11 +31,12 @@ import {useIsMobile} from '@/hooks/use-is-mobile'
import {useLaunchApp} from '@/hooks/use-launch-app'
import {useShortcuts} from '@/hooks/use-shortcuts'
import {cn} from '@/lib/utils'
import {appStateToString} from '@/modules/app-store/app-state-strings'
import {resolveShortcutUrl} from '@/modules/desktop/shortcut-dialog'
import {resolveShortcutIcon, ShortcutIconImage} from '@/modules/desktop/shortcut-icon-image'
import {systemAppsKeyed, useApps} from '@/providers/apps'
import {useAvailableApps} from '@/providers/available-apps'
import {AppState, trpcReact} from '@/trpc/trpc'
import {trpcReact} from '@/trpc/trpc'
import {AppIcon} from './app-icon'
import {FadeScroller} from './fade-scroller'
@@ -444,20 +444,3 @@ const SearchItem = (props: ComponentPropsWithoutRef<typeof CommandItem>) => {
/>
)
}
export function appStateToString(appState: AppState, t: TFunction) {
return {
'not-installed': t('app.install'),
installing: t('app.installing'),
ready: t('app.open'),
running: t('app.open'),
starting: t('app.restarting'),
restarting: t('app.starting'),
stopping: t('app.stopping'),
updating: t('app.updating'),
uninstalling: t('app.uninstalling'),
unknown: t('app.offline'),
stopped: t('app.offline'),
loading: t('loading'),
}[appState]
}
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import {act} from 'react'
import {createRoot} from 'react-dom/client'
import {MemoryRouter} from 'react-router-dom'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import type {RegistryApp, UserApp} from '@/trpc/trpc'
import {InstallButtonConnected} from './install-button-connected'
const fixtures = vi.hoisted(() => ({
app: {
appStoreId: 'umbrel-app-store',
id: 'incompatible-update',
name: 'Incompatible Update',
version: '2.0.0',
manifestVersion: '2.0.0',
compatible: false,
} as RegistryApp,
userApp: {
id: 'incompatible-update',
version: '1.0.0',
state: 'ready',
} as UserApp,
launch: vi.fn(),
update: vi.fn(),
}))
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof import('react-i18next')>()),
useTranslation: () => ({t: (key: string) => key}),
}))
vi.mock('@/hooks/use-app-install', () => ({
useAppInstall: () => ({state: 'ready', progress: undefined, install: vi.fn()}),
}))
vi.mock('@/hooks/use-launch-app', () => ({useLaunchApp: () => fixtures.launch}))
vi.mock('@/hooks/use-update-app', () => ({
useUpdateApp: () => ({update: fixtures.update, isPending: false}),
}))
vi.mock('@/providers/apps', () => ({
useApps: () => ({isLoading: false, userAppsKeyed: {[fixtures.userApp.id]: fixtures.userApp}}),
}))
vi.mock('@/providers/available-apps', () => ({
useAllAvailableApps: () => ({apps: [fixtures.app], ambiguousAppIds: new Set(), isLoading: false}),
}))
vi.mock('@/modules/app-store/select-dependencies-dialog', () => ({SelectDependenciesDialog: () => null}))
vi.mock('@/modules/app-store/os-update-required', () => ({
OSUpdateRequiredDialog: ({open}: {open: boolean}) => (open ? <div role='dialog'>os-update-required</div> : null),
}))
vi.mock('@/trpc/trpc', () => ({
installedStates: ['ready', 'running', 'stopped'],
progressBarStates: ['installing', 'updating'],
trpcReact: {user: {get: {useQuery: () => ({data: {role: 'owner'}})}}},
}))
;(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true
describe('InstallButtonConnected', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
})
test('keeps Open and explains an incompatible update without mutating', () => {
act(() =>
root.render(
<MemoryRouter>
<InstallButtonConnected app={fixtures.app} />
</MemoryRouter>,
),
)
const buttons = [...container.querySelectorAll('button')]
const openButton = buttons.find((button) => button.textContent?.includes('app.open'))
const updateButton = buttons.find((button) => button.textContent?.includes('app-updates.update'))
expect(openButton).toBeDefined()
expect(updateButton).toBeDefined()
act(() => updateButton?.click())
expect(fixtures.update).not.toHaveBeenCalled()
expect(container.querySelector('[role="dialog"]')?.textContent).toBe('os-update-required')
act(() => openButton?.click())
expect(fixtures.launch).toHaveBeenCalledWith(fixtures.app.id)
})
})
@@ -3,11 +3,15 @@ import {useImperativeHandle, useState} from 'react'
import {useTranslation} from 'react-i18next'
import {arrayIncludes} from 'ts-extras'
import {Button} from '@/components/ui/button'
import {toast} from '@/components/ui/toast'
import {useAppInstall} from '@/hooks/use-app-install'
import {useLaunchApp} from '@/hooks/use-launch-app'
import {useUpdateApp} from '@/hooks/use-update-app'
import {getDependencyAlternatives} from '@/modules/app-store/dependency-alternatives'
import {OSUpdateRequiredDialog} from '@/modules/app-store/os-update-required'
import {SelectDependenciesDialog} from '@/modules/app-store/select-dependencies-dialog'
import {canPresentUpdateAction, isAppUpdateAvailable} from '@/modules/app-store/update-availability'
import {useApps} from '@/providers/apps'
import {useAllAvailableApps} from '@/providers/available-apps'
import {installedStates, RegistryApp, trpcReact} from '@/trpc/trpc'
@@ -19,11 +23,12 @@ export function InstallButtonConnected({app, ref}: {app: RegistryApp; ref?: Reac
const appInstall = useAppInstall(app.id)
// Members browse the app store read-only, only the owner can install
const isMember = trpcReact.user.get.useQuery().data?.role === 'member'
const {apps} = useAllAvailableApps()
const {apps, ambiguousAppIds} = useAllAvailableApps()
const [showDepsDialog, setShowDepsDialog] = useState(false)
const [showOSUpdateRequiredDialog, setShowOSUpdateRequiredDialog] = useState(false)
const {userAppsKeyed, isLoading} = useApps()
const openApp = useLaunchApp()
const updateApp = useUpdateApp(app.id)
const [selections, setSelections] = useState({} as Record<string, string>)
const [highlightDependency, setHighlightDependency] = useState<string | undefined>(undefined)
@@ -34,7 +39,7 @@ export function InstallButtonConnected({app, ref}: {app: RegistryApp; ref?: Reac
},
}))
if (isLoading || !userAppsKeyed || !apps) {
if (isLoading || !userAppsKeyed || !apps || !ambiguousAppIds) {
return (
<InstallButton
key={app.id}
@@ -53,37 +58,13 @@ export function InstallButtonConnected({app, ref}: {app: RegistryApp; ref?: Reac
setSelections({...selections})
}
const getAppsImplementing = (dependencyId: string) =>
apps
// Filter out community apps that aren't installed
.filter((registryApp) => {
const isCommunityApp = registryApp.appStoreId !== 'umbrel-app-store'
return !isCommunityApp || userAppsKeyed[registryApp.id]
})
// Prefer installed app over registry app
.map((registryApp) => userAppsKeyed[registryApp.id] ?? registryApp)
.filter((applicableApp) => applicableApp.implements?.includes(dependencyId))
.map((implementingApp) => implementingApp.id)
// Obtain possible alternatives for each dependency. Groups alternatives for
// each dependency into a two dimensional array, where each item references
// both the original dependency and the alterantive app. First item always is
// the original dependency.
// [
// [{dependencyId, appId: dependencyId}, {dependencyId, appId: implementingId}],
// [{dependencyId, appId: dependencyId}],
// ]
const dependencies = (app.dependencies ?? []).map((dependencyId) =>
[dependencyId, ...getAppsImplementing(dependencyId)].map((appId) => ({
dependencyId,
appId,
})),
)
const isAppIdAmbiguous = ambiguousAppIds.has(app.id)
const dependencies = getDependencyAlternatives(app.dependencies, apps, userAppsKeyed, ambiguousAppIds)
// Auto-select the first installed alternative, naturally preferring the original
// app when it is installed as well.
dependencies.forEach((alternatives) => {
alternatives.forEach(({dependencyId, appId}) => {
dependencies.forEach(({dependencyId, appIds}) => {
appIds.forEach((appId) => {
if (!selections[dependencyId] && isInstalled(appId)) {
selectAlternative(dependencyId, appId)
}
@@ -92,8 +73,8 @@ export function InstallButtonConnected({app, ref}: {app: RegistryApp; ref?: Reac
// TODO: Also check if app is ready? `&& userAppsKeyed[dep].state === 'ready'`
// Will want to mark apps as in progress so we don't show that an app needs to be installed first
const areAllAlternativesSelectedAndInstalled = dependencies.every((alternatives) =>
alternatives.some((app) => selections[app.dependencyId] === app.appId && isInstalled(app.appId)),
const areAllAlternativesSelectedAndInstalled = dependencies.every(({dependencyId, appIds}) =>
appIds.some((appId) => selections[dependencyId] === appId && isInstalled(appId)),
)
const install = () => {
@@ -101,6 +82,10 @@ export function InstallButtonConnected({app, ref}: {app: RegistryApp; ref?: Reac
toast(t('app-store.ask-owner-to-install'), {area: 'app-store'})
return
}
if (isAppIdAmbiguous) {
toast(t('app-store.app-id-conflict'), {area: 'app-store'})
return
}
if (!app.compatible) {
setShowOSUpdateRequiredDialog(true)
return
@@ -116,25 +101,63 @@ export function InstallButtonConnected({app, ref}: {app: RegistryApp; ref?: Reac
}
const verifyInstall = (selectedDeps: Record<string, string>) => {
if (isAppIdAmbiguous) return
// Currently always the case because AppPermissionsDialog checks
if (areAllAlternativesSelectedAndInstalled) {
appInstall.install(selectedDeps)
}
}
// Open remains the primary installed-app action. Updates are offered
// separately so an incompatible update never blocks launching the app.
const userApp = userAppsKeyed[app.id]
const updateAvailable = !isMember && !!userApp && isAppUpdateAvailable(userApp.version, app)
const canOfferUpdate = updateAvailable && canPresentUpdateAction(appInstall.state)
const update = () => {
if (isAppIdAmbiguous) {
toast(t('app-store.app-id-conflict'), {area: 'app-store'})
return
}
if (!canPresentUpdateAction(appInstall.state)) return
if (!app.compatible) {
setShowOSUpdateRequiredDialog(true)
return
}
updateApp.update()
}
return (
<>
<InstallButton
// `key` to prevent framer-motion from thinking install buttons from different pages are the same and animating between them
key={app.id}
installSize={app.installSize ? prettyBytes(app.installSize) : undefined}
// progress={userApp?.installProgress}
// state={userApp?.state || 'initial'}
progress={appInstall.progress}
state={appInstall.state}
onInstallClick={install}
onOpenClick={() => openApp(app.id)}
/>
<div className='flex flex-col gap-1.5'>
<div className='flex items-center gap-3 max-sm:flex-col max-sm:items-stretch md:gap-4'>
<InstallButton
// `key` to prevent framer-motion from thinking install buttons from different pages are the same and animating between them
key={app.id}
installSize={app.installSize ? prettyBytes(app.installSize) : undefined}
// progress={userApp?.installProgress}
// state={userApp?.state || 'initial'}
progress={appInstall.progress}
state={appInstall.state}
onInstallClick={install}
onOpenClick={() => openApp(app.id)}
disabled={isAppIdAmbiguous && appInstall.state === 'not-installed'}
/>
{canOfferUpdate && (
<Button
size='lg'
onClick={update}
disabled={isAppIdAmbiguous || updateApp.isPending}
className='max-sm:h-[30px] max-sm:w-full max-sm:text-13'
>
{t('app-updates.update')}
</Button>
)}
</div>
{isAppIdAmbiguous && (
<p className='max-w-64 text-right text-11 leading-tight text-amber-200/70 max-sm:text-center'>
{t('app-store.app-id-conflict')}
</p>
)}
<SelectDependenciesDialog
appId={app.id}
dependencies={dependencies}
@@ -148,6 +171,6 @@ export function InstallButtonConnected({app, ref}: {app: RegistryApp; ref?: Reac
open={showOSUpdateRequiredDialog}
onOpenChange={setShowOSUpdateRequiredDialog}
/>
</>
</div>
)
}
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import {describe, expect, test} from 'vitest'
import {getInstallButtonAction} from './install-button'
describe('getInstallButtonAction', () => {
test('keeps Open available for a running or ready installed app', () => {
expect(getInstallButtonAction('ready')).toBe('open')
expect(getInstallButtonAction('running')).toBe('open')
})
test('offers Install only before installation', () => {
expect(getInstallButtonAction('not-installed')).toBe('install')
})
test.each(['installing', 'updating', 'uninstalling', 'starting', 'restarting', 'stopping'] as const)(
'has no primary action while %s',
(state) => expect(getInstallButtonAction(state)).toBeUndefined(),
)
})
+23 -11
View File
@@ -18,9 +18,10 @@ type Props = {
state: AppStateOrLoading
onInstallClick?: () => void
onOpenClick?: () => void
disabled?: boolean
}
export function InstallButton({installSize, progress, state, onInstallClick, onOpenClick, ...props}: Props) {
export function InstallButton({installSize, progress, state, onInstallClick, onOpenClick, disabled, ...props}: Props) {
return (
<ProgressButton
variant={state === 'updating' ? 'default' : 'primary'}
@@ -28,18 +29,23 @@ export function InstallButton({installSize, progress, state, onInstallClick, onO
state={state}
progress={progress}
onClick={() => {
if (state === 'not-installed') {
onInstallClick?.()
} else if (state === 'ready') {
onOpenClick?.()
switch (getInstallButtonAction(state)) {
case 'install':
return onInstallClick?.()
case 'open':
return onOpenClick?.()
case undefined:
return
}
}}
className='hover:bg-brand-lighter max-md:h-[30px] max-md:w-full max-md:text-13'
// Phones (below sm) get the compact full-width form; this is the
// breakpoint where the app page hero drops the actions to their own
// row (app-hero.tsx) — keep the two in sync
className='hover:bg-brand-lighter max-sm:h-[30px] max-sm:w-full max-sm:text-13'
style={{
['--progress-button-bg' as string]: state === 'updating' ? 'hsl(0 0 30%)' : 'hsl(var(--color-brand))',
}}
disabled={!arrayIncludes(['not-installed', 'ready'], state)}
initial={{borderRadius: 9999}}
disabled={disabled || !getInstallButtonAction(state)}
{...props}
>
<ButtonContentForState state={state} installSize={installSize} progress={progress} />
@@ -47,6 +53,12 @@ export function InstallButton({installSize, progress, state, onInstallClick, onO
)
}
export function getInstallButtonAction(state: AppStateOrLoading): 'install' | 'open' | undefined {
if (state === 'not-installed') return 'install'
if (arrayIncludes(['ready', 'running'], state)) return 'open'
return undefined
}
function ButtonContentForState({
state,
installSize,
@@ -82,9 +94,9 @@ function ButtonContentForState({
case 'running':
return t('app.open')
case 'starting':
return t('app.restarting') + '...'
case 'restarting':
return t('app.starting') + '...'
case 'restarting':
return t('app.restarting') + '...'
case 'stopping':
return t('app.stopping') + '...'
case 'uninstalling':
@@ -102,5 +114,5 @@ function ButtonContentForState({
export const installButtonClass = cn(
tw`whitespace-nowrap disabled:bg-brand/60 disabled:opacity-100 bg-brand hover:bg-brand-lighter`,
tw`max-md:h-[30px] max-md:w-full max-md:text-13`,
tw`max-sm:h-[30px] max-sm:w-full max-sm:text-13`,
)
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import {describe, expect, test} from 'vitest'
import {isProgressButtonDisabled} from './progress-button'
describe('isProgressButtonDisabled', () => {
test('defers settled-state eligibility to the caller', () => {
expect(isProgressButtonDisabled('not-installed')).toBe(false)
expect(isProgressButtonDisabled('ready')).toBe(false)
expect(isProgressButtonDisabled('running')).toBe(false)
expect(isProgressButtonDisabled('stopped')).toBe(false)
expect(isProgressButtonDisabled('unknown')).toBe(false)
})
test.each(['installing', 'updating', 'uninstalling', 'starting', 'restarting', 'stopping'] as const)(
'locks the button while %s',
(state) => expect(isProgressButtonDisabled(state)).toBe(true),
)
test('combines the lifecycle lock with the caller lock', () => {
expect(isProgressButtonDisabled('ready', true)).toBe(true)
expect(isProgressButtonDisabled('stopped', true)).toBe(true)
expect(isProgressButtonDisabled('installing', false)).toBe(true)
expect(isProgressButtonDisabled('loading', false)).toBe(true)
})
})
+29 -27
View File
@@ -1,7 +1,5 @@
import {VariantProps} from 'class-variance-authority'
import {HTMLMotionProps, motion} from 'motion/react'
import {CSSProperties, useEffect, useState} from 'react'
import {useFirstMountState} from 'react-use'
import {ButtonHTMLAttributes, CSSProperties, useEffect, useState} from 'react'
import {arrayIncludes} from 'ts-extras'
import {buttonVariants} from '@/components/ui/button'
@@ -22,12 +20,16 @@ if (typeof CSS !== 'undefined' && CSS.registerProperty) {
type Props = {
progress?: number
state: AppStateOrLoading
onClick?: () => void
} & VariantProps<typeof buttonVariants> &
HTMLMotionProps<'button'>
ButtonHTMLAttributes<HTMLButtonElement>
/**
* An app action button (Install / Open / Update…) whose background can fill
* with live progress during a transition. Deliberately a plain, intrinsically
* sized button: the label crossfades via CSS and progress is a custom property,
* so grids can render many instances without layout measurement observers.
*/
export function ProgressButton({variant, size, progress, state, children, className, style, ...buttonProps}: Props) {
const isFirstRender = useFirstMountState()
const progressing = arrayIncludes(progressBarStates, state)
// Stops flicker when progressing done
@@ -41,9 +43,7 @@ export function ProgressButton({variant, size, progress, state, children, classN
}, [state, progressing])
const progressingStyle: CSSProperties = {
// Adding transitions so hover and other transitions work
transition: '--progress-button-progress 0.3s',
// ['--progress-button-bg' as string]: 'var(--color-brand)',
['--progress-button-progress' as string]: `${Math.round(progress ?? 0)}%`,
backgroundImage:
'linear-gradient(to right, var(--progress-button-bg) var(--progress-button-progress), transparent var(--progress-button-progress))',
@@ -52,11 +52,11 @@ export function ProgressButton({variant, size, progress, state, children, classN
}
return (
<motion.button
<button
data-progressing={progressing}
className={cn(
buttonVariants({size, variant}),
'whitespace-nowrap disabled:opacity-60',
'overflow-hidden whitespace-nowrap transition-[background-color,opacity] duration-300 ease-out disabled:opacity-60',
state === 'loading' && '!bg-white/10',
// Disable transition right when installing done for a sec to prevent flicker
state === 'ready' && !progressingDone && 'transition-none',
@@ -66,24 +66,26 @@ export function ProgressButton({variant, size, progress, state, children, classN
...(progressing ? progressingStyle : undefined),
...style,
}}
layout
disabled={!arrayIncludes(['not-installed', 'ready'], state)}
{...buttonProps}
disabled={isProgressButtonDisabled(state, buttonProps.disabled)}
>
{/* Child has `layout` too to prevent content from being scaled and stretched with the parent */}
{/* https://codesandbox.io/p/sandbox/framer-motion-2-scale-correction-z4tgr?file=%2Fsrc%2FApp.js&from-embed= */}
<motion.div
layout='position'
key={state}
initial={{opacity: 0}}
animate={{
opacity: 1,
transition: {opacity: {duration: 0.2, delay: state === 'loading' || isFirstRender ? 0 : 0.2}},
}}
// className='bg-red-500/50'
>
{children}
</motion.div>
</motion.button>
{/* Stable intrinsic wrapper; the keyed child re-fades when state changes */}
<span className='flex w-max items-center'>
<span key={state} className='flex animate-in items-center duration-200 fade-in'>
{children}
</span>
</span>
</button>
)
}
/** Loading and lifecycle transitions are never interactive, regardless of caller props. */
export function isProgressButtonDisabled(state: AppStateOrLoading, callerDisabled = false) {
return (
callerDisabled ||
arrayIncludes(
['loading', 'installing', 'updating', 'uninstalling', 'starting', 'restarting', 'stopping'] as const,
state,
)
)
}
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import {act} from 'react'
import {createRoot} from 'react-dom/client'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {CarouselDots, useCarouselAutoAdvance, type CarouselApi} from './carousel'
vi.mock('motion/react', () => ({useReducedMotion: () => false}))
;(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true
let progress: number | undefined
function AutoAdvanceHarness({api, paused}: {api: CarouselApi; paused: boolean}) {
progress = useCarouselAutoAdvance(api, {intervalMs: 1000, paused})
return null
}
describe('carousel accessibility', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.useRealTimers()
})
test('keeps slide dots in the tab order and identifies the active slide', () => {
act(() => root.render(<CarouselDots count={3} activeIndex={1} onSelect={() => {}} />))
const dots = [...container.querySelectorAll('button')]
expect(dots).toHaveLength(3)
expect(dots.every((dot) => dot.tabIndex === 0)).toBe(true)
expect(dots[1]?.getAttribute('aria-current')).toBe('true')
})
test('holds autoplay while paused and starts a fresh countdown on resume', () => {
vi.useFakeTimers()
const scrollNext = vi.fn()
const api = {
on: vi.fn(),
off: vi.fn(),
canScrollNext: vi.fn(() => true),
scrollNext,
scrollTo: vi.fn(),
} as unknown as CarouselApi
act(() => root.render(<AutoAdvanceHarness api={api} paused={false} />))
act(() => vi.advanceTimersByTime(500))
expect(progress).toBeGreaterThan(0)
act(() => root.render(<AutoAdvanceHarness api={api} paused />))
const heldProgress = progress
act(() => vi.advanceTimersByTime(1000))
expect(progress).toBe(heldProgress)
expect(scrollNext).not.toHaveBeenCalled()
act(() => root.render(<AutoAdvanceHarness api={api} paused={false} />))
expect(progress).toBe(0)
act(() => vi.advanceTimersByTime(1000))
expect(scrollNext).toHaveBeenCalledTimes(1)
})
})
+189 -3
View File
@@ -1,6 +1,8 @@
import useEmblaCarousel, {type UseEmblaCarouselType} from 'embla-carousel-react'
import {ArrowLeft, ArrowRight} from 'lucide-react'
import {useReducedMotion} from 'motion/react'
import * as React from 'react'
import {TbChevronLeft, TbChevronRight} from 'react-icons/tb'
import {Button} from '@/components/ui/button'
import {cn} from '@/lib/utils'
@@ -139,13 +141,19 @@ function Carousel({
function CarouselContent({
className,
containerClassName,
ref,
...props
}: React.HTMLAttributes<HTMLDivElement> & {ref?: React.Ref<HTMLDivElement>}) {
}: React.HTMLAttributes<HTMLDivElement> & {
/** Extra classes for the overflow-clipping viewport (e.g. inner padding so
* slides align to the content grid while bleeding to the sheet edges) */
containerClassName?: string
ref?: React.Ref<HTMLDivElement>
}) {
const {carouselRef, orientation} = useCarousel()
return (
<div ref={carouselRef} className='overflow-hidden'>
<div ref={carouselRef} className={cn('overflow-hidden', containerClassName)}>
<div
ref={ref}
className={cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', className)}
@@ -235,4 +243,182 @@ function CarouselNext({
)
}
export {type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext}
/** The carousel's snap positions and which one is active, kept in sync with the api */
function useCarouselSnaps(api: CarouselApi) {
const [snapCount, setSnapCount] = React.useState(0)
const [activeIndex, setActiveIndex] = React.useState(0)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
React.useEffect(() => {
if (!api) return
const update = () => {
setSnapCount(api.scrollSnapList().length)
setActiveIndex(api.selectedScrollSnap())
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}
update()
api.on('select', update)
api.on('reInit', update)
return () => {
api.off('select', update)
api.off('reInit', update)
}
}, [api])
return {snapCount, activeIndex, canScrollPrev, canScrollNext}
}
/**
* Advances the carousel on a timer and reports the countdown as 0–100 for the
* active dot's fill. The countdown holds while the user is dragging, hovering,
* focusing carousel content, or while the tab is hidden. It restarts whenever
* the slide changes and is disabled entirely under reduced motion.
*/
function useCarouselAutoAdvance(api: CarouselApi, {intervalMs = 5000, enabled = true, paused = false} = {}) {
const reducedMotion = useReducedMotion()
const [progress, setProgress] = React.useState(0)
const run = enabled && !reducedMotion
React.useEffect(() => {
if (!api || !run || paused) return
let elapsed = 0
let last = performance.now()
let held = false
setProgress(0)
const onPointerDown = () => (held = true)
const onPointerUp = () => (held = false)
const onSelect = () => {
elapsed = 0
setProgress(0)
}
// 10Hz is plenty: the dot's fill transition smooths between updates
const timer = window.setInterval(() => {
const now = performance.now()
const delta = now - last
last = now
if (held || document.hidden) return
elapsed += delta
if (elapsed >= intervalMs) {
elapsed = 0
if (api.canScrollNext()) api.scrollNext()
else api.scrollTo(0)
}
setProgress(Math.min(100, (elapsed / intervalMs) * 100))
}, 100)
api.on('pointerDown', onPointerDown)
api.on('pointerUp', onPointerUp)
api.on('select', onSelect)
return () => {
window.clearInterval(timer)
api.off('pointerDown', onPointerDown)
api.off('pointerUp', onPointerUp)
api.off('select', onSelect)
}
}, [api, run, intervalMs, paused])
return run ? progress : undefined
}
/**
* Pill dot indicators: the active dot stretches into a pill and, when a
* progress value is provided (auto-advance countdown, video position…), fills
* up left to right; without one it renders solid.
*/
function CarouselDots({
count,
activeIndex,
onSelect,
progress,
className,
}: {
count: number
activeIndex: number
onSelect: (index: number) => void
/** 0–100 fill of the active dot; omit for a solid active dot */
progress?: number
className?: string
}) {
if (count <= 1) return null
return (
<div className={cn('flex items-center justify-center gap-1', className)}>
{Array.from({length: count}).map((_, index) => {
const isActive = index === activeIndex
return (
<button
key={index}
onClick={() => onSelect(index)}
className='group rounded-full p-1 outline-hidden transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-white/40 motion-reduce:transition-none'
aria-label={`Go to slide ${index + 1}`}
aria-current={isActive ? 'true' : undefined}
>
<div
className={cn(
'relative h-1.5 overflow-hidden rounded-full transition-all duration-300 motion-reduce:transition-none',
isActive ? 'w-10 bg-white/40 group-hover:bg-white/60' : 'w-1.5 bg-white/40 group-hover:bg-white/60',
)}
>
{isActive && (
<div
className='absolute inset-y-0 left-0 rounded-full bg-white transition-all duration-300 ease-linear motion-reduce:transition-none'
style={{width: `${progress ?? 100}%`}}
/>
)}
</div>
</button>
)
})}
</div>
)
}
const carouselArrowClass =
'flex h-9 w-9 items-center justify-center rounded-full bg-white/10 outline-hidden transition-colors hover:bg-white/16 focus-visible:ring-2 focus-visible:ring-white/25 disabled:opacity-40 disabled:hover:bg-white/10'
/** Round previous/next buttons for a carousel's control row (the
* apps.umbrel.com banner treatment), sitting opposite the dots */
function CarouselArrows({
onPrev,
onNext,
disablePrev = false,
disableNext = false,
className,
}: {
onPrev: () => void
onNext: () => void
disablePrev?: boolean
disableNext?: boolean
className?: string
}) {
return (
<div className={cn('flex items-center gap-2', className)}>
{/* Chevrons, matching the spotlight banners' View pill */}
<button aria-label='Previous slide' onClick={onPrev} disabled={disablePrev} className={carouselArrowClass}>
<TbChevronLeft className='h-4 w-4' />
</button>
<button aria-label='Next slide' onClick={onNext} disabled={disableNext} className={carouselArrowClass}>
<TbChevronRight className='h-4 w-4' />
</button>
</div>
)
}
export {
type CarouselApi,
Carousel,
CarouselArrows,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
useCarouselAutoAdvance,
useCarouselSnaps,
}
@@ -6,16 +6,16 @@ import {tw} from '@/utils/tw'
// The dark glass surface itself, shared with hand-positioned tooltips (e.g. the
// dock, which anchors its own label so it tracks icon magnification).
export const machinesTooltipClass = cn(
export const darkTooltipClass = cn(
materialSurfaceClasses.contextMenu,
tw`relative isolate overflow-hidden rounded-full px-2.5 py-1 text-12 font-medium -tracking-2 whitespace-nowrap text-white`,
)
// Dark glass tooltip matching the Machines look. Wrap any focusable element:
// <MachinesTooltip label="Restart"><button .../></MachinesTooltip>
// Dark glass tooltip (originally from Machines). Wrap any focusable element:
// <DarkTooltip label="Restart"><button .../></DarkTooltip>
// Uses the Radix primitives directly (rather than components/ui/tooltip) so the
// content renders in a portal — otherwise ancestors with overflow-hidden clip it.
export function MachinesTooltip({
export function DarkTooltip({
label,
side = 'top',
children,
@@ -32,7 +32,7 @@ export function MachinesTooltip({
side={side}
sideOffset={8}
className={cn(
machinesTooltipClass,
darkTooltipClass,
'z-50 animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
)}
>
+3 -1
View File
@@ -75,13 +75,15 @@ const DialogScrollableContent = ({
children,
showClose,
onOpenAutoFocus,
className,
}: {
children: React.ReactNode
showClose?: boolean
onOpenAutoFocus?: (e: Event) => void
className?: string
}) => {
return (
<DialogContent className='flex flex-col p-0' onOpenAutoFocus={onOpenAutoFocus}>
<DialogContent className={cn('flex flex-col p-0', className)} onOpenAutoFocus={onOpenAutoFocus}>
{/* TODO: adjust dialog inset if `showClose` is true so close button isn't too close to scrollbar */}
<ScrollArea className='flex flex-col' dialogInset>
{children}
@@ -0,0 +1,6 @@
// Shared motion timing (originally from Machines). One easing for layout
// morphs so shared-element glides and container resizes across features all
// move in lockstep.
export const layoutMorphTransition = {
layout: {duration: 0.35, ease: [0.32, 0.72, 0, 1] as [number, number, number, number]},
}
+22
View File
@@ -0,0 +1,22 @@
export const UMBREL_APP_STORE_ID = 'umbrel-app-store'
export type AppIdentity = {
registryId: string
appId: string
}
export function appPath(appId: string) {
return `/app-store/${appId}`
}
export function communityAppPath(registryId: string, appId: string) {
return `/community-app-store/${registryId}/${appId}`
}
export function appPathForIdentity({registryId, appId}: AppIdentity) {
return registryId === UMBREL_APP_STORE_ID ? appPath(appId) : communityAppPath(registryId, appId)
}
export function registryAppPath(app: {appStoreId: string; id: string}) {
return appPathForIdentity({registryId: app.appStoreId, appId: app.id})
}
@@ -0,0 +1,74 @@
# App Store
The umbrelOS App Store: an OS-native storefront over the locally synced app
registry, optionally decorated by editorial content from apps.umbrel.com.
## Local truth, remote taste
The locally synced registry (`appStore.registry` via `AvailableAppsProvider`)
decides which apps exist, their current versions, compatibility, dependencies,
and every action. The remote v3 API (`data/storefront-query.ts`,
`data/releases.ts`) may only decorate or order apps that already exist locally:
- Every remote app id is resolved against the local registry; unknown ids are
silently dropped (`data/storefront.ts` → `resolveStorefront`).
- Sections without enough locally available content disappear entirely.
- `updatedAt` metadata is applied only when the remote entry describes the
exact version the local registry offers; `createdAt` is version-insensitive.
- Release history is accepted only for the same app id and current version,
and never shows releases newer than what this device can install
(`data/releases.ts` → `reconcileReleases`).
- A failed, slow (>3s), blocked, malformed, or outdated remote response is
treated exactly like having no remote data: the complete local store renders
with no spinner, no error card, and no toast. Discover then hands over to
All apps as the landing page, and the feed is retried in the background.
- Pages wait for the feed's first attempt (`hooks/use-storefront.ts`) so
Discover composes once — editorial sections and catalog together — rather
than the catalog appearing alone with the sections landing on top of it. A
feed that has already failed is never waited for again, and the query keeps
its data cached for the session so reopening the store is instant.
Search (`data/search.ts`) is local-only and never triggers a network request.
Date-based sorting (`data/catalog.ts`) is offered only while remote metadata
provides usable dates — options are hidden, not disabled, offline.
## v3 API compatibility rules
The feed schema (`data/storefront.ts`) is versioned with `schemaVersion: 1`
and a deliberately tiny section vocabulary: `app-list` (grid/rail),
`spotlight` (one section of banners — each just an app id and its artwork,
since the composition is the image), and `category-feature`. Unknown future section types and
individually malformed sections are dropped without failing the feed, so the
API can add new types without breaking released clients. Artwork must be
hosted on the same origin as the API itself. Curated content is edited in
`private-apps-umbrel-com/lib/umbrelos-v3.js` — add or reorder sections there;
this client needs no changes as long as the section uses the existing types.
## Structure
- `routes.tsx` — route objects, spread into `router.tsx` under `SheetLayout`
- `index.tsx` — store shell: title, search, updates chip, category rail
- `components/` — pages (`discover`, `category`, `app-page`) and sections
- `data/` — pure schemas, selectors, and query definitions (unit-tested)
- `hooks/` — thin bindings of the pure data layer to providers/queries
- `providers/store-actions.tsx` — shared install/update/open mutations plus
the dialogs they can raise, so every app card offers a working action
button without mounting per-card state
On desktop the app page hero (`components/app-page/app-hero.tsx`) pins to the
top of the sheet and collapses with the scroll position itself — icon and
name scale into a compact bar, then a blurred surface pops in underneath.
Pages compose in with the store's shared reveal (`storeRevealClass` /
`storeRevealDelay` in `constants.ts`, keyframes in `index.css`): sections
rise gently out of a blur, each a small beat behind the last, and the shell
keys its content by page identity so switching categories replays it. Inside
the collapsing hero use the soft (blur-and-fade only) variant — a transform
there would fight the scroll-driven motion values, and an in-flight transform
on any ancestor of the sticky wrapper would unpin it.
The install/open/dependency flow (`InstallButtonConnected`,
`SelectDependenciesDialog`, `OSUpdateRequiredDialog`) and the update hooks
(`useAppsWithUpdates`, `useUpdateAllApps`) are shared with the rest of the OS
and live outside the feature. Community app stores reuse this feature's grid
and app-page composition from `routes/community-app-store/`.
@@ -0,0 +1,269 @@
import {useTranslation} from 'react-i18next'
import {Link} from 'react-router-dom'
import {arrayIncludes} from 'ts-extras'
import {AppIcon} from '@/components/app-icon'
import {ProgressButton} from '@/components/progress-button'
import {appPath} from '@/features/app-store/constants'
import {getAppStoreAction, type AppStoreStatus} from '@/features/app-store/data/catalog'
import {preloadFirstFewGalleryImages} from '@/features/app-store/data/gallery-preload'
import {useStoreActions} from '@/features/app-store/providers/store-actions'
import {pollStates} from '@/hooks/use-app-install'
import {cn} from '@/lib/utils'
import {appStateToString} from '@/modules/app-store/app-state-strings'
import {canPresentUpdateAction} from '@/modules/app-store/update-availability'
import type {AppStateOrLoading, RegistryApp} from '@/trpc/trpc'
import {tw} from '@/utils/tw'
// One primary app-card system with a small number of explicit variants:
// - AppCard: standard grid tile (icon, name, two-line tagline, working action)
// - AppCardCompact: horizontal rails and related-apps lists
// Cards navigate to details; the action button offers the one obvious verb
// (Install / Open / Update) without leaving the page.
export function AppCard({
app,
status,
lifecycleState,
progress,
to,
className,
style,
}: {
app: RegistryApp
status?: AppStoreStatus
lifecycleState?: AppStateOrLoading
progress?: number
/** Override the link target (community stores) */
to?: string
className?: string
style?: React.CSSProperties
}) {
return (
<div
onMouseEnter={() => preloadFirstFewGalleryImages(app)}
style={style}
className={cn(
// content-visibility lets the browser skip layout/paint (and reveal
// animations) for offscreen cards — the full catalog renders ~370
'[contain-intrinsic-size:auto_78px] [content-visibility:auto]',
'group relative flex w-full items-center gap-3 rounded-15 p-2.5 transition-colors duration-200 focus-within:bg-white/7 hover:bg-white/7',
className,
)}
>
{/* Stretched link: the whole card navigates, while the action button
below stays an independent, valid interactive element */}
<Link
to={to ?? appPath(app.id)}
state={{fromAppStore: true}}
aria-label={app.name}
className='absolute inset-0 rounded-15 outline-hidden focus-visible:ring-2 focus-visible:ring-white/20'
/>
{/* Sized in CSS: AppIcon's size prop is an inline style, which would
silently beat the md: step */}
<AppIcon src={app.icon} className='pointer-events-none relative size-[52px] rounded-12 md:size-14' />
<div className='pointer-events-none relative flex min-w-0 flex-1 flex-col gap-0.5'>
<h3 className='truncate text-14 leading-tight font-semibold -tracking-3 md:text-15'>{app.name}</h3>
<p className='line-clamp-2 w-full min-w-0 text-12 leading-tight opacity-40 md:text-13'>{app.tagline}</p>
</div>
<AppCardAction app={app} status={status} lifecycleState={lifecycleState} progress={progress} />
</div>
)
}
// Faded action pill, matching the toast action buttons. Overrides the Button
// variant's border/shadow so idle and progress states share one silhouette.
const cardActionClass = tw`relative z-10 h-7 shrink-0 rounded-full border-0 bg-white/10 px-3 text-12 shadow-none hover:bg-white/16 focus-visible:ring-2 focus-visible:ring-white/25`
// Same subdued fill the updates dialog uses for its progress buttons
const cardActionProgressBg = {['--progress-button-bg' as string]: 'hsl(0 0 30%)'}
/**
* The one obvious action for an app's current status: Install, Open, or
* Update. One button per card, always mounted, so state changes (idle → live
* progress fill → done) play out inside a single element with no shared
* animation machinery — an app listed several times on one page just renders
* several independent buttons.
*/
export function AppCardAction({
app,
status,
lifecycleState,
progress,
}: {
app: RegistryApp
status?: AppStoreStatus
lifecycleState?: AppStateOrLoading
progress?: number
}) {
const {t} = useTranslation()
const actions = useStoreActions()
// No status data or no actions provider in this subtree: show nothing
if (!status || !actions) return null
const state = lifecycleState ?? (status === 'available' || status === 'incompatible' ? 'not-installed' : 'ready')
// The live app state is the authority on "in progress" — it flips
// optimistically the moment a mutation starts, ahead of the derived status
const transitioning = arrayIncludes(pollStates, state)
const showProgress = transitioning && arrayIncludes(['installing', 'updating'], state) && progress !== undefined
const candidateAction = getAppStoreAction(status, transitioning)
const appIdConflict =
actions.isAppIdAmbiguous(app.id) && (candidateAction === 'install' || candidateAction === 'update')
const action = appIdConflict
? undefined
: candidateAction === 'update' && !canPresentUpdateAction(state)
? undefined
: candidateAction
// `in-progress` status without a transitioning state can only be a brief
// cache mismatch; showing the transition label there too keeps it coherent
const label = transitioning
? `${appStateToString(state, t)}...`
: status === 'in-progress'
? `${t('app-store.status.in-progress')}...`
: {
// Incompatible apps get the same Install button; the click explains
// the required OS update, exactly like the app page does
available: t('app.install'),
incompatible: t('app.install'),
installed: t('app.open'),
'update-available': t('app-updates.update'),
}[status]
const onClick = () => {
switch (action) {
case 'install':
return actions.installApp(app)
case 'open':
return actions.openApp(app.id)
case 'update':
return actions.updateApp(app)
case undefined:
return
}
}
return (
<span
className='relative z-10 shrink-0'
title={appIdConflict ? t('app-store.app-id-conflict') : undefined}
tabIndex={appIdConflict ? 0 : undefined}
>
<ProgressButton
size='sm'
// Not-installed statuses act regardless of the (absent) app state
state={!transitioning && arrayIncludes(['available', 'incompatible'], status) ? 'not-installed' : state}
progress={progress}
onClick={onClick}
disabled={!action}
className={cardActionClass}
style={cardActionProgressBg}
>
{label}
{showProgress && (
<span className='ml-1 inline-block w-[4ch] text-right opacity-60'>{Math.round(progress)}%</span>
)}
</ProgressButton>
</span>
)
}
/**
* A rail tile in the style of the Machines OS catalog: its own card surface,
* a blurred glow of the icon leaning in on hover, and the icon's reflection
* off the card surface below it.
*/
export function AppCardCompact({app, status}: {app: RegistryApp; status?: AppStoreStatus}) {
return (
<Link
to={appPath(app.id)}
state={{fromAppStore: true}}
onMouseEnter={() => preloadFirstFewGalleryImages(app)}
className='group relative flex w-[140px] shrink-0 flex-col items-center gap-2.5 rounded-24 p-4 pt-5 outline-hidden focus-visible:ring-2 focus-visible:ring-white/25 md:w-[164px]'
>
{/* Card surface with the glow clipped inside it */}
<div
aria-hidden
className='settings-edge-material absolute inset-0 overflow-hidden rounded-24 bg-white/5 transition-colors duration-300 group-hover:bg-white/8'
>
{/* The glow takes only the icon's hue/saturation, blended over a
fixed mid-gray luminosity base — so dark and bright icons cast
the same perceived amount of light (pure compositor work) */}
<div className='absolute top-4 left-1/2 isolate h-20 w-20 -translate-x-1/2 scale-125 opacity-30 blur-xl transition-opacity duration-500 group-hover:opacity-45'>
<div className='absolute inset-0 bg-neutral-400' />
<img src={app.icon} alt='' className='absolute inset-0 h-full w-full mix-blend-color' draggable={false} />
</div>
</div>
<div className='relative flex flex-col items-center gap-2'>
{/* NOTE: AppIcon's size prop sets inline width/height, so responsive
size classes would silently lose — keep every copy at exactly 72 */}
<AppIcon
src={app.icon}
size={72}
className='rounded-15 transition-transform duration-300 group-hover:scale-[1.05]'
/>
{/* The icon's reflection off the card surface: a mirrored copy fading
downward behind the name (which is `relative`, painting above).
Origin sits at the mirrored icon's center so it grows away from
the contact line exactly as the icon grows toward it. */}
<div aria-hidden className='pointer-events-none absolute top-[72px] left-1/2 h-12 w-[72px] -translate-x-1/2'>
<div className='origin-[50%_40px] [mask-image:linear-gradient(to_bottom,black,transparent_70%)] opacity-10 blur-[2px] transition-transform duration-300 group-hover:scale-[1.05]'>
<AppIcon src={app.icon} size={72} className='-scale-y-100 rounded-15' />
</div>
</div>
<div className='relative flex w-full min-w-0 flex-col items-center gap-0.5 pt-0.5 text-center'>
<h3 className='w-full truncate text-13 leading-tight font-semibold -tracking-2 md:text-14'>{app.name}</h3>
<AppStatusLabel
status={status && status !== 'available' && status !== 'incompatible' ? status : undefined}
fallback={<span className='w-full truncate text-12 opacity-35'>{app.developer}</span>}
/>
</div>
</div>
</Link>
)
}
export function AppStatusLabel({
status,
className,
fallback,
}: {
status?: AppStoreStatus
className?: string
fallback?: React.ReactNode
}) {
const {t} = useTranslation()
if (!status || status === 'available') return fallback ? <>{fallback}</> : null
const labelClass = cn('text-11 leading-tight font-medium whitespace-nowrap', className)
switch (status) {
case 'installed':
return <span className={cn(labelClass, 'text-white/35')}>{t('app.installed')}</span>
case 'update-available':
return <span className={cn(labelClass, 'text-brand-lightest')}>{t('app-store.status.update-available')}</span>
case 'in-progress':
return <span className={cn(labelClass, 'umbrel-pulse text-white/50')}>{t('app-store.status.in-progress')}</span>
case 'incompatible':
return <span className={cn(labelClass, 'text-white/30')}>{t('app-store.status.incompatible')}</span>
}
}
// Small chip used inside artwork cards (spotlight / category features)
export function AppChip({app, className}: {app: RegistryApp; className?: string}) {
return (
<Link
to={appPath(app.id)}
state={{fromAppStore: true}}
onMouseEnter={() => preloadFirstFewGalleryImages(app)}
className={cn(
'flex shrink-0 items-center gap-1.5 rounded-full bg-black/35 py-1.5 pr-3 pl-1.5 outline-hidden backdrop-blur-md transition-colors duration-200 hover:bg-black/55 focus-visible:ring-2 focus-visible:ring-white/40',
className,
)}
>
<AppIcon src={app.icon} size={22} className='rounded-6' />
<span className='text-12 font-medium whitespace-nowrap text-white/90'>{app.name}</span>
</Link>
)
}
@@ -0,0 +1,92 @@
import {ReactNode} from 'react'
import {useTranslation} from 'react-i18next'
import {AppCard} from '@/features/app-store/components/app-card'
import {SectionHeading} from '@/features/app-store/components/section-heading'
import {
APP_STORE_EMPTY_STATE_SRC,
appGridClass,
storeCardPaddedClass,
storeRevealCardClass,
storeRevealDelay,
} from '@/features/app-store/constants'
import type {AppStoreStatus} from '@/features/app-store/data/catalog'
import {useAppCardStateMap} from '@/features/app-store/hooks/use-app-status'
import {cn} from '@/lib/utils'
import type {RegistryApp} from '@/trpc/trpc'
export function AppGrid({
apps,
statuses,
makeTo,
className,
revealDelayStart,
}: {
apps: readonly RegistryApp[]
statuses?: Map<string, AppStoreStatus>
/** Override link targets (community stores) */
makeTo?: (app: RegistryApp) => string
className?: string
/** When set, cards trickle in with a tiny per-card stagger from this delay */
revealDelayStart?: number
}) {
const stagger = revealDelayStart !== undefined
const appStates = useAppCardStateMap(apps)
return (
<div className={cn(appGridClass, className)}>
{apps.map((app, index) => {
const actionState = appStates.get(app.id)
return (
<AppCard
key={app.id}
app={app}
status={statuses?.get(app.id)}
lifecycleState={actionState?.state}
progress={actionState?.progress}
to={makeTo?.(app)}
className={stagger ? storeRevealCardClass : undefined}
style={stagger ? storeRevealDelay(revealDelayStart + Math.min(index * 12, 240)) : undefined}
/>
)
})}
</div>
)
}
/** A titled, contained grid section — the workhorse of the storefront */
export function AppGridSection({
overline,
title,
rightChildren,
apps,
statuses,
makeTo,
children,
}: {
overline?: string
title: ReactNode
rightChildren?: ReactNode
apps: readonly RegistryApp[]
statuses?: Map<string, AppStoreStatus>
makeTo?: (app: RegistryApp) => string
children?: ReactNode
}) {
return (
<section className={cn(storeCardPaddedClass, 'flex flex-col gap-4')}>
<SectionHeading overline={overline} title={title} rightChildren={rightChildren} />
<AppGrid apps={apps} statuses={statuses} makeTo={makeTo} />
{children}
</section>
)
}
export function AppStoreEmptyState({title, description}: {title: string; description?: string}) {
const {t} = useTranslation()
return (
<div className='flex flex-col items-center justify-center gap-3 px-6 py-12 text-center'>
<img src={APP_STORE_EMPTY_STATE_SRC} alt='' className='h-24 w-24 md:h-28 md:w-28' loading='lazy' />
<p className='text-15 font-semibold text-white/80'>{title || t('app-store.search.no-results')}</p>
{description && <p className='max-w-sm text-13 text-white/40'>{description}</p>}
</div>
)
}
@@ -0,0 +1,111 @@
import {useQuery} from '@tanstack/react-query'
import {useRef} from 'react'
import {ErrorBoundary} from 'react-error-boundary'
import {useTranslation} from 'react-i18next'
import {useNavigate, useParams} from 'react-router-dom'
import {InstallButton} from '@/components/install-button'
import {InstallButtonConnected} from '@/components/install-button-connected'
import {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'
import {Loading} from '@/components/ui/loading'
import {registryAppPath} from '@/constants/app-store'
import {AppGrid} from '@/features/app-store/components/app-grid'
import {SectionHeading} from '@/features/app-store/components/section-heading'
import {storeRevealClass, storeRevealDelay} from '@/features/app-store/constants'
import {getRelatedApps} from '@/features/app-store/data/catalog'
import {appReleasesQueryOptions, reconcileReleases} from '@/features/app-store/data/releases'
import {useAppStatusMap} from '@/features/app-store/hooks/use-app-status'
import {StoreActionsProvider} from '@/features/app-store/providers/store-actions'
import {cn} from '@/lib/utils'
import {isAppUpdateAvailable} from '@/modules/app-store/update-availability'
import {useApps} from '@/providers/apps'
import {useAvailableApp, useAvailableApps} from '@/providers/available-apps'
import {useLinkToDialog} from '@/utils/dialog'
import {AppPageHero} from './app-page/app-hero'
import {AppPageContent} from './app-page/app-page-content'
import {appPageWrapperClass} from './app-page/shared'
export default function AppPage() {
const {t} = useTranslation()
const {appId} = useParams()
const {app, isLoading} = useAvailableApp(appId)
const linkToDialog = useLinkToDialog()
const navigate = useNavigate()
const {apps, isLoading: isLoadingApps} = useAvailableApps()
const {userAppsKeyed, isLoading: isLoadingUserApps} = useApps()
const statuses = useAppStatusMap()
// Optional release history; any failure just falls back to local notes
const releasesQ = useQuery({...appReleasesQueryOptions(appId ?? ''), enabled: Boolean(appId)})
const installButtonRef = useRef<{triggerInstall: (highlightDependency?: string) => void}>(null)
if (isLoading || isLoadingApps || isLoadingUserApps) return <Loading />
if (!app) throw new Error('App not found')
const userApp = userAppsKeyed?.[app.id]
const relatedApps = getRelatedApps(apps ?? [], app.id, 6)
const releaseTimeline = reconcileReleases(app, releasesQ.data)
const highlightLatestRelease = Boolean(userApp && isAppUpdateAvailable(userApp.version, app))
const showDependencies = (dependencyId?: string) => {
if (userApp) {
// Installed app: dependencies are managed from the app settings dialog
const params = {for: app.id} as Record<string, string>
if (dependencyId) params.dependency = dependencyId
navigate(linkToDialog('app-settings', params))
} else {
// Otherwise surface the choice inside the install flow
installButtonRef.current?.triggerInstall(dependencyId)
}
}
return (
<StoreActionsProvider>
{/* Keyed by app so navigating between app pages (same route, new
params — e.g. via related apps) remounts and replays the reveal */}
<div key={app.id} className={appPageWrapperClass}>
<AppPageHero
app={app}
childrenRight={
<div className='flex items-center gap-3 max-sm:flex-col max-sm:items-stretch md:gap-4'>
<ErrorBoundary
fallback={
<div className='pointer-events-none opacity-50'>
<InstallButton state='not-installed' />
</div>
}
>
<InstallButtonConnected ref={installButtonRef} app={app} />
</ErrorBoundary>
</div>
}
/>
<div className='flex flex-col gap-6 md:gap-8'>
<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>
<AppPageContent
app={app}
userApp={userApp}
releaseTimeline={releaseTimeline}
highlightLatestRelease={highlightLatestRelease}
showDependencies={showDependencies}
registryId={app.appStoreId}
makeAppPath={registryAppPath}
/>
{relatedApps.length > 0 && (
<section
className={cn('flex flex-col gap-4 px-2.5 md:px-0', storeRevealClass)}
style={storeRevealDelay(380)}
>
<SectionHeading title={t('app-page.section.recommendations.title')} />
<AppGrid apps={relatedApps} statuses={statuses} />
</section>
)}
</ErrorBoundary>
</div>
</div>
</StoreActionsProvider>
)
}
@@ -0,0 +1,100 @@
import PhotoSwipeLightbox from 'photoswipe/lightbox'
import {useEffect, useState} from 'react'
import 'photoswipe/style.css'
import {
Carousel,
CarouselArrows,
CarouselContent,
CarouselDots,
CarouselItem,
useCarouselSnaps,
type CarouselApi,
} from '@/components/ui/carousel'
import {FadeInImg} from '@/components/ui/fade-in-img'
/**
* The app's screenshots as a draggable carousel with pill dot indicators
* (shared with the Discover banners and What's new): slides align to the
* content grid but bleed to the sheet edges, and clicking a screenshot still
* opens the full-size lightbox.
*/
export function AppGallery({gallery, galleryId}: {gallery: string[]; galleryId: string}) {
const [api, setApi] = useState<CarouselApi>()
const {snapCount, activeIndex, canScrollPrev, canScrollNext} = useCarouselSnaps(api)
useEffect(() => {
let lightbox: PhotoSwipeLightbox | null = new PhotoSwipeLightbox({
gallery: '#' + galleryId,
children: 'a',
pswpModule: () => import('photoswipe'),
})
lightbox.init()
return () => {
lightbox?.destroy()
lightbox = null
}
}, [galleryId])
if (gallery.length === 0) return null
return (
<section>
<Carousel
setApi={setApi}
className='-mx-3 md:-mx-[40px] xl:-mx-[70px]'
opts={{align: 'start', containScroll: 'trimSnaps'}}
>
<CarouselContent
containerClassName='px-3 md:px-[40px] xl:px-[70px]'
className='pswp-gallery -ml-2.5 md:-ml-4'
id={galleryId}
>
{gallery.map((src, i) => (
<CarouselItem
// Index key: some manifests repeat the same image URL
key={i}
className='basis-auto pl-2.5 md:pl-4'
>
<a
href={src}
data-pswp-width={2880}
data-pswp-height={1800}
// The after: hairline sits over the image edge, so it reads on bright
// artwork too and doesn't change the image box
className='group relative block aspect-1.6 h-[220px] animate-in overflow-hidden rounded-24 bg-white/6 ring-white/80 outline-hidden fill-mode-both slide-in-from-right-10 fade-in ring-inset after:pointer-events-none after:absolute after:inset-0 after:rounded-24 after:ring-1 after:ring-white/8 after:ring-inset focus-visible:ring-4 md:h-[360px]'
style={{
animationDelay: `${i * 0.1}s`,
}}
target='_blank'
rel='noreferrer'
draggable={false}
>
<FadeInImg
src={src}
loading={i > 2 ? 'lazy' : undefined}
className='h-full w-full object-cover group-focus-visible:opacity-80'
alt=''
draggable={false}
/>
</a>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
{snapCount > 1 && (
<div className='mt-3 flex items-center justify-between'>
<CarouselDots count={snapCount} activeIndex={activeIndex} onSelect={(index) => api?.scrollTo(index)} />
<CarouselArrows
onPrev={() => api?.scrollPrev()}
onNext={() => api?.scrollNext()}
disablePrev={!canScrollPrev}
disableNext={!canScrollNext}
/>
</div>
)}
</section>
)
}
@@ -0,0 +1,249 @@
import {motion, useTransform} from 'motion/react'
import {ReactNode, useLayoutEffect, useRef, useState} from 'react'
import {useTranslation} from 'react-i18next'
import {TbCircleArrowLeftFilled} from 'react-icons/tb'
import {useLocation, useNavigate} from 'react-router-dom'
import {AppIcon} from '@/components/app-icon'
import {storeRevealDelay, storeRevealSoftClass} from '@/features/app-store/constants'
import {useStickyCollapse} from '@/features/app-store/hooks/use-sticky-collapse'
import {useIsMobile} from '@/hooks/use-is-mobile'
import {cn} from '@/lib/utils'
import {SheetFixedContent} from '@/modules/sheet-top-fixed'
import {useSheetStickyHeader} from '@/providers/sheet-sticky-header'
import {RegistryApp} from '@/trpc/trpc'
import {dialogHeaderCircleButtonClass} from '@/utils/element-classes'
type HeroProps = {app: RegistryApp; childrenRight: ReactNode}
export function AppPageHero({app, childrenRight}: HeroProps) {
const isMobile = useIsMobile()
return (
<>
<SheetBackButton />
{isMobile ? (
<MobileAppHero app={app} childrenRight={childrenRight} />
) : (
<DesktopAppHero app={app} childrenRight={childrenRight} />
)}
</>
)
}
// The floating back button: a mirror of the sheet's close button
// (layouts/sheet.tsx and dialog-close-button.tsx — keep the insets and icon
// size in sync) portaled into the sheet's fixed layer, so it sits in the
// corner at every scroll position, including over the collapsed desktop bar
function SheetBackButton() {
const {t} = useTranslation()
const handleBack = useBackNavigation()
return (
<SheetFixedContent>
<button
className={cn(dialogHeaderCircleButtonClass, 'absolute top-3 left-3 z-60 sm:top-5 sm:left-5')}
onClick={handleBack}
aria-label={t('back')}
>
<TbCircleArrowLeftFilled className='h-5 w-5 lg:h-6 lg:w-6' />
</button>
</SheetFixedContent>
)
}
// ---------------------------------------------------------------------------
// Desktop: scroll-collapsing hero
// ---------------------------------------------------------------------------
// The hero pins at the top of the sheet and collapses with the scroll: the
// icon and name scale down into their compact positions as you scroll (driven
// by the scroll position itself, not a time-based animation), the tagline and
// attribution dissolve, the action buttons stay put and settle into the bar,
// and once everything reaches its final size the blurred bar surface pops in
// underneath. Scrolling back up plays the exact same motion in reverse.
// Geometry of the collapse (px, desktop). The wrapper occupies the hero's
// natural height and stays sticky; everything inside is absolutely positioned
// and interpolated between its hero and bar placements. The bar's centerline
// is dictated by the sheet's corner buttons (top-5 + 24px icon = center 32px
// from the sheet corner): the bar is exactly twice that, so back button,
// icon, name, actions, and the cross all share one line. The back button
// lives in the corner permanently (SheetBackButton), so the hero starts right
// at the sheet's content padding with no row of its own above.
const HERO_HEIGHT = 120 // the hero row: the icon's height
const BAR_HEIGHT = 64
const BAR_CENTER = BAR_HEIGHT / 2
const COLLAPSE_DISTANCE = 90 // scroll depth that scrubs the collapse
const ICON_HERO = 120
const ICON_BAR = 36
// The corner buttons' inset from the sheet corner and their icon size
// (layouts/sheet.tsx: top-5 left/right-5; dialog-close-button.tsx: lg:h-6)
const CORNER_INSET = 20
const CORNER_BUTTON = 24
function DesktopAppHero({app, childrenRight}: HeroProps) {
const {scrollRef} = useSheetStickyHeader()
// 0 = full hero, 1 = fully collapsed bar. Clamped, so before the pin and
// past the collapse it just rests at the endpoints.
const wrapperRef = useRef<HTMLDivElement>(null)
const progress = useStickyCollapse(wrapperRef, COLLAPSE_DISTANCE)
// How far the sheet's content is padded from its edge, to know whether the
// bar's icon would collide with the cornered back button
const [contentInset, setContentInset] = useState(70)
useLayoutEffect(() => {
const measure = () => {
const wrapper = wrapperRef.current
const scroller = scrollRef?.current
if (!wrapper || !scroller) return
setContentInset(wrapper.getBoundingClientRect().left - scroller.getBoundingClientRect().left)
}
measure()
window.addEventListener('resize', measure)
return () => window.removeEventListener('resize', measure)
}, [scrollRef])
// Icon scales into the bar, landing on the content grid's left edge — unless
// the sheet padding is too tight to clear the cornered back button, in which
// case it shifts right just enough to keep a 12px gap after it
const iconFinalX = Math.max(0, CORNER_INSET + CORNER_BUTTON + 12 - contentInset)
const iconX = useTransform(progress, [0, 1], [0, iconFinalX])
const iconY = useTransform(progress, [0, 1], [0, BAR_CENTER - ICON_BAR / 2])
const iconScale = useTransform(progress, [0, 1], [1, ICON_BAR / ICON_HERO])
// Radius grows pre-scale so the scaled-down icon lands on the bar's rounded-8
const iconRadius = useTransform(progress, [0, 1], [30, 8 / (ICON_BAR / ICON_HERO)])
// Name glides left and shrinks from text-32 to text-19, its cap-trimmed text
// box landing centered on the bar's centerline next to the icon
const nameX = useTransform(progress, [0, 1], [140, iconFinalX + ICON_BAR + 10])
const nameY = useTransform(progress, [0, 1], [18, 25])
const nameScale = useTransform(progress, [0, 1], [1, 19 / 32])
// Tagline and attribution dissolve early in the collapse
const metaOpacity = useTransform(progress, [0, 0.5], [1, 0])
const metaY = useTransform(progress, [0, 1], [54, BAR_CENTER + 24])
// The actions stay at the right and just settle into the bar
const actionsY = useTransform(progress, [0, 1], [40, BAR_CENTER - 20])
// The bar surface pops in only once everything is in its final position;
// while visible, clicking its empty area scrolls back to the top
const surfaceOpacity = useTransform(progress, [0.9, 1], [0, 1])
const surfacePointerEvents = useTransform(progress, (p) => (p > 0.999 ? 'auto' : 'none'))
return (
<div
ref={wrapperRef}
data-sticky-chrome
className='pointer-events-none sticky top-0 z-40'
style={{height: HERO_HEIGHT}}
>
{/* The solid bar surface, bleeding through the sheet's padding to
its edges — pops in once the collapse completes. Deliberately not
frosted: Chromium can't sample this nested scroller for
backdrop-filter, so a clean black bar beats a broken blur. */}
<motion.div
aria-hidden
style={{
opacity: surfaceOpacity,
pointerEvents: surfacePointerEvents,
height: BAR_HEIGHT,
boxShadow: '2px 2px 2px 0px #FFFFFF0D inset',
}}
onClick={() => scrollRef?.current?.scrollTo({top: 0, behavior: 'smooth'})}
className='umbrel-window-surface-top absolute -inset-x-3 top-0 border-b border-white/10 bg-black md:-inset-x-[40px] xl:-inset-x-[70px]'
/>
{/* The motion wrappers own the scroll-driven transforms, so the entry
reveal (blur-and-fade only, never a transform) lives on their inner
elements — each a small beat after the last */}
{/* The radius lives on the wrapper (so it can animate) and the icon
inherits it — rounding the image itself, rather than clipping it,
keeps the icon's 1px border intact along the curves */}
<motion.div
style={{x: iconX, y: iconY, scale: iconScale, borderRadius: iconRadius}}
className='absolute top-0 left-0 origin-top-left'
>
<AppIcon
src={app.icon}
size={ICON_HERO}
// Quieter edge than the default AppIcon border: at this size the
// 10% line reads as a frame
className={cn('rounded-[inherit] border-white/5', storeRevealSoftClass)}
style={storeRevealDelay(40)}
/>
</motion.div>
<motion.div
style={{x: nameX, y: nameY, scale: nameScale}}
className='absolute top-0 right-[300px] left-0 flex origin-top-left items-center'
>
<h1
className={cn('min-w-0 truncate text-32 leading-inter-trimmed font-semibold', storeRevealSoftClass)}
style={storeRevealDelay(90)}
>
{app.name}
</h1>
</motion.div>
<motion.div style={{x: 140, y: metaY, opacity: metaOpacity}} className='absolute top-0 right-[300px] left-0'>
<div className={cn('flex flex-col gap-1.5', storeRevealSoftClass)} style={storeRevealDelay(130)}>
<p className='truncate text-15 leading-tight opacity-50'>{app.tagline}</p>
<p className='truncate text-13 text-white/35'>{app.developer}</p>
</div>
</motion.div>
<motion.div style={{y: actionsY}} className='pointer-events-auto absolute top-0 right-0'>
<div className={cn('flex items-center gap-3 md:gap-4', storeRevealSoftClass)} style={storeRevealDelay(170)}>
{childrenRight}
</div>
</motion.div>
</div>
)
}
// ---------------------------------------------------------------------------
// Mobile and tablet: simple hero (no sticky collapse)
// ---------------------------------------------------------------------------
// Phones (below sm) stack: a compact identity row, then the actions on their
// own full-width row (the install button stretches itself there — keep its
// breakpoint in sync, see install-button.tsx). Anything wider, up to the
// desktop hero at lg, has the room for a proper hero: larger icon and type
// with the actions inline on the right, like desktop minus the scroll collapse.
function MobileAppHero({app, childrenRight}: HeroProps) {
return (
<div className={cn('space-y-5', storeRevealSoftClass)}>
<div
data-testid='app-top'
className='mt-5 flex flex-col items-stretch gap-5 sm:flex-row sm:items-center sm:gap-6'
>
<div className='flex min-w-0 flex-1 items-center gap-3 sm:gap-4'>
{/* 64px on phones, 96px from sm — sized in CSS so the breakpoint
stays in step with the type; 25% corners like the desktop hero */}
<AppIcon src={app.icon} className='size-16 rounded-15 border-white/5 sm:size-24 sm:rounded-24' />
<div className='flex min-w-0 flex-col items-start gap-1 py-1 sm:gap-1.5'>
<h1 className='text-16 leading-inter-trimmed font-semibold sm:text-24'>{app.name}</h1>
<p className='line-clamp-2 w-full text-12 leading-tight opacity-50 sm:text-15'>{app.tagline}</p>
<p className='hidden truncate text-13 text-white/35 sm:block'>{app.developer}</p>
</div>
</div>
<div className='sm:shrink-0'>{childrenRight}</div>
</div>
</div>
)
}
function useBackNavigation() {
const navigate = useNavigate()
const location = useLocation()
return () => {
if (location.state?.fromAppStore) {
navigate(-1)
} else {
// Came from outside the app store (e.g. Files, home screen, deep link),
// so navigate to the app store root instead of leaking into another section
const isCommunity = location.pathname.startsWith('/community-app-store/')
if (isCommunity) {
const storeId = location.pathname.split('/')[2]
navigate(`/community-app-store/${storeId}`)
} else {
navigate('/app-store')
}
}
}
}
@@ -0,0 +1,75 @@
import {storeRevealClass, storeRevealDelay} from '@/features/app-store/constants'
import {hasTimelineContent, type ReleaseTimelineEntry} from '@/features/app-store/data/releases'
import {cn} from '@/lib/utils'
import {RegistryApp, UserApp} from '@/trpc/trpc'
import {AppGallery} from './app-gallery'
import {AboutSection, CredentialsSection, DependenciesSection, InfoBand} from './app-sections'
import {ReleaseTimeline} from './release-timeline'
/**
* The body of an app page: gallery, the info band, then the description with
* the release timeline (and installed-app extras) alongside it. One responsive
* tree — on small screens the columns simply stack.
*/
export function AppPageContent({
app,
userApp,
releaseTimeline = [],
highlightLatestRelease = false,
showDependencies,
registryId = app.appStoreId,
makeAppPath,
}: {
app: RegistryApp
/** When the user initiates an install, we have a user app even before install */
userApp?: UserApp
releaseTimeline?: ReleaseTimelineEntry[]
highlightLatestRelease?: boolean
showDependencies?: (dependencyId?: string) => void
registryId?: string
makeAppPath?: (app: RegistryApp) => string
}) {
const hasDependencies = (app.dependencies?.length ?? 0) > 0
const showTimeline = hasTimelineContent(releaseTimeline)
const hasSideColumn = showTimeline || !!userApp || hasDependencies
return (
<>
{/* The page composes itself top to bottom: gallery, then the info
band, then the two columns — each a beat behind the last */}
{app.gallery.length > 0 && (
<div className={storeRevealClass} style={storeRevealDelay(120)}>
<AppGallery galleryId={'gallery-' + app.id} gallery={app.gallery} />
</div>
)}
<div className={storeRevealClass} style={storeRevealDelay(200)}>
<InfoBand app={app} />
</div>
<div
className={cn(
'flex flex-col gap-8 px-2.5 md:px-0',
hasSideColumn && 'lg:grid lg:grid-cols-[minmax(0,1.7fr)_minmax(280px,1fr)] lg:items-start lg:gap-11',
)}
>
<div className={storeRevealClass} style={storeRevealDelay(260)}>
<AboutSection app={app} />
</div>
{hasSideColumn && (
<div className={cn('flex flex-col gap-7', storeRevealClass)} style={storeRevealDelay(320)}>
{userApp && <CredentialsSection userApp={userApp} />}
{hasDependencies && (
<DependenciesSection
app={app}
registryId={registryId}
showDependencies={showDependencies}
makeAppPath={makeAppPath}
/>
)}
{showTimeline && <ReleaseTimeline entries={releaseTimeline} highlightLatest={highlightLatestRelease} />}
</div>
)}
</div>
</>
)
}
@@ -0,0 +1,230 @@
import {ReactNode} from 'react'
import {useTranslation} from 'react-i18next'
import {HiExternalLink} from 'react-icons/hi'
import {TbCircleCheckFilled} from 'react-icons/tb'
import {Link} from 'react-router-dom'
import {arrayIncludes} from 'ts-extras'
import {AppIcon} from '@/components/app-icon'
import {CopyableField} from '@/components/ui/copyable-field'
import {registryAppPath} from '@/constants/app-store'
import {resolveDependencyRegistryApp} from '@/lib/app-store-registry'
import {cn} from '@/lib/utils'
import {getAppsImplementingDependency} from '@/modules/app-store/dependency-alternatives'
import {useApps} from '@/providers/apps'
import {useAllAvailableApps} from '@/providers/available-apps'
import {installedStates, RegistryApp, UserApp} from '@/trpc/trpc'
import {appPageSectionLabelClass, ReadMoreMarkdownSection} from './shared'
/**
* Horizontal band of app facts below the gallery: version, compatibility,
* source, attribution, and support — the page's reference row, closed off by
* a hairline. On desktop it spans the full content width with the leftover
* space distributed evenly between items (apps.umbrel.com style); on smaller
* screens it falls back to a 3-column, then 2-column grid.
*/
export function InfoBand({app}: {app: RegistryApp}) {
const {t} = useTranslation()
return (
<section className='grid grid-cols-2 gap-x-8 gap-y-5 border-b border-white/10 px-2.5 pb-6 md:grid-cols-3 md:px-0 md:pb-7 lg:flex lg:justify-between'>
<BandItem label={t('app-page.section.info.version')} value={app.version} />
<BandItem
label={t('app-page.section.info.compatibility')}
value={
app.compatible ? (
t('app-page.section.info.compatibility-compatible')
) : (
<span className='text-amber-300/80'>{t('app-page.section.info.compatibility-not-compatible')}</span>
)
}
/>
{app.repo && (
<BandItem
label={t('app-page.section.info.source-code')}
value={<BandLink href={app.repo}>{t('app-page.section.info.source-code.public')}</BandLink>}
/>
)}
<BandItem
label={t('app-page.section.info.developer')}
value={<BandLink href={app.website}>{app.developer}</BandLink>}
/>
{app.submission && app.submitter && (
<BandItem
label={t('app-page.section.info.submitted-by')}
value={<BandLink href={app.submission}>{app.submitter}</BandLink>}
/>
)}
<BandItem
label={t('app-store.info.support')}
value={<BandLink href={app.support}>{t('app-page.section.info.support')}</BandLink>}
/>
</section>
)
}
function BandItem({label, value}: {label: ReactNode; value: ReactNode}) {
return (
<div className='flex min-w-0 flex-col gap-1.5'>
<span className='text-12 leading-tight font-medium text-white/50'>{label}</span>
<span className='truncate text-14 font-medium'>{value}</span>
</div>
)
}
// White external link with a dotted underline and the inline external-link
// glyph (faded until hover), mirroring the home screen's shortcut labels
function BandLink({href, children}: {href: string; children: ReactNode}) {
return (
<a
href={href}
target='_blank'
rel='noreferrer'
className='group underline decoration-white/30 decoration-dotted underline-offset-4 outline-hidden transition-colors hover:decoration-white/60 focus-visible:decoration-white/60'
>
{children}
<HiExternalLink className='ml-1 inline h-3.5 w-3.5 align-[-0.18em] text-white/50 transition-colors group-hover:text-white' />
</a>
)
}
// The description flows directly on the sheet, headed by the same muted
// label as the "What's new" timeline alongside it
export function AboutSection({app}: {app: RegistryApp}) {
const {t} = useTranslation()
return (
<div className='flex flex-col gap-2.5'>
<h2 className={cn(appPageSectionLabelClass, 'mb-1.5')}>{t('app-page.section.about')}</h2>
{/* Key resets the read-more state when the content changes */}
<ReadMoreMarkdownSection key={app.description} lines={11}>
{app.description}
</ReadMoreMarkdownSection>
</div>
)
}
export function CredentialsSection({userApp}: {userApp: UserApp}) {
const {t} = useTranslation()
if (!userApp.credentials) return null
const {defaultUsername, defaultPassword} = userApp.credentials
if (!defaultUsername && !defaultPassword) return null
return (
<section className='flex flex-col gap-3'>
<h2 className={appPageSectionLabelClass}>{t('app-page.section.credentials.title')}</h2>
{defaultUsername && (
<CredentialsRow label={t('default-credentials.username')}>
<CopyableField className='w-[120px]' narrow value={defaultUsername} />
</CredentialsRow>
)}
{defaultPassword && (
<CredentialsRow label={t('default-credentials.password')}>
<CopyableField narrow className='w-[120px]' value={defaultPassword} isPassword />
</CredentialsRow>
)}
</section>
)
}
function CredentialsRow({label, children}: {label: ReactNode; children: ReactNode}) {
return (
<div className='flex flex-row items-center gap-2'>
<span className='flex-1 text-14 opacity-50'>{label}</span>
{children}
</div>
)
}
export function DependenciesSection({
app,
registryId = app.appStoreId,
showDependencies,
makeAppPath = registryAppPath,
}: {
app: RegistryApp
registryId?: string
showDependencies?: (dependencyId?: string) => void
makeAppPath?: (app: RegistryApp) => string
}) {
const {t} = useTranslation()
const availableApps = useAllAvailableApps()
const {userAppsKeyed, isLoading: isLoadingUserApps} = useApps()
if (availableApps.isLoading || isLoadingUserApps) return null
const {apps, ambiguousAppIds, repoAppsKeyed} = availableApps
return (
<section className='flex flex-col gap-3'>
<h2 className={appPageSectionLabelClass}>{t('app-page.section.requires')}</h2>
{app.dependencies?.map((dependencyId) => {
const dependencyRegistryApp = resolveDependencyRegistryApp({
dependencyId,
registryId,
repoAppsKeyed,
ambiguousAppIds,
})
const dependencyApp = ambiguousAppIds?.has(dependencyId)
? undefined
: (dependencyRegistryApp ?? userAppsKeyed?.[dependencyId])
if (!dependencyApp) {
return (
<div key={dependencyId} className='flex w-full min-w-0 flex-col'>
<h3 className='truncate text-14 leading-tight font-semibold -tracking-3'>{dependencyId}</h3>
<span className='text-12 text-white/40'>{t('app-store.dependency-metadata-unavailable')}</span>
</div>
)
}
const dependencyPath = dependencyRegistryApp ? makeAppPath(dependencyRegistryApp) : undefined
const dependencyUserApp = userAppsKeyed?.[dependencyId]
const numberOfAlternatives = getAppsImplementingDependency(
apps,
userAppsKeyed,
dependencyId,
ambiguousAppIds,
).length
const installed = !!dependencyUserApp && arrayIncludes(installedStates, dependencyUserApp.state)
const alternativesLabel = t('app-page.section.dependencies.n-alternatives', {
count: numberOfAlternatives + /* the app itself */ 1,
})
return (
<div key={dependencyId} className='flex w-full items-center gap-2.5'>
{dependencyPath ? (
<Link to={dependencyPath} state={{fromAppStore: true}}>
<AppIcon src={dependencyApp.icon} size={36} className='rounded-8' />
</Link>
) : (
<AppIcon src={dependencyApp.icon} size={36} className='rounded-8' />
)}
<div className='flex min-w-0 flex-col'>
{dependencyPath ? (
<Link to={dependencyPath} state={{fromAppStore: true}} className='flex items-center gap-1.5'>
<h3 className='truncate text-14 leading-tight font-semibold -tracking-3'>{dependencyApp.name}</h3>
{installed && <TbCircleCheckFilled className='h-4 w-4 shrink-0 text-white/40' />}
</Link>
) : (
<div className='flex items-center gap-1.5'>
<h3 className='truncate text-14 leading-tight font-semibold -tracking-3'>{dependencyApp.name}</h3>
{installed && <TbCircleCheckFilled className='h-4 w-4 shrink-0 text-white/40' />}
</div>
)}
{numberOfAlternatives > 0 &&
(showDependencies ? (
<button
className='self-start text-12 text-brand-lightest hover:text-brand-lighter'
onClick={() => showDependencies(dependencyId)}
>
{alternativesLabel}
</button>
) : (
<span className='self-start text-12 text-white/40'>{alternativesLabel}</span>
))}
</div>
</div>
)
})}
</section>
)
}
@@ -0,0 +1,166 @@
import {format, formatDistanceToNowStrict} from 'date-fns'
import {motion} from 'motion/react'
import {useState} from 'react'
import {useTranslation} from 'react-i18next'
import type {ReleaseTimelineEntry} from '@/features/app-store/data/releases'
import {useLanguage} from '@/hooks/use-language'
import {cn} from '@/lib/utils'
import {languageCodeToDateLocale} from '@/utils/date-time'
import {appPageSectionLabelClass, expandTransition, ReadMoreMarkdownSection} from './shared'
/**
* "What's new" as a vertical release timeline (the apps.umbrel.com design):
* a hairline spine with a dot per release — the newest one lit up and
* rippling — version rows that expand into their notes, and relative dates.
*/
export function ReleaseTimeline({
entries,
highlightLatest = false,
}: {
entries: ReleaseTimelineEntry[]
/** Marks the newest entry as an available update for this device */
highlightLatest?: boolean
}) {
const {t} = useTranslation()
// One entry open at a time, like an accordion: opening a release folds the
// previous one away. The newest release starts open.
const [openVersion, setOpenVersion] = useState<string | null>(entries[0]?.version ?? null)
// Callers gate on hasTimelineContent; this is just a safety net
if (!entries.length) return null
return (
<section>
<p className={cn(appPageSectionLabelClass, 'mb-4')}>{t('app-page.section.release-notes.title')}</p>
<div
className={cn(
'relative pl-7',
// The spine only makes sense when it has releases to connect
entries.length > 1 &&
'before:absolute before:top-3.5 before:bottom-3 before:left-[7px] before:w-px before:bg-white/10',
)}
>
{entries.map((entry, index) => (
<TimelineRelease
key={entry.version}
entry={entry}
latest={index === 0}
highlight={index === 0 && highlightLatest}
open={entry.version === openVersion}
onToggle={() => setOpenVersion((current) => (current === entry.version ? null : entry.version))}
/>
))}
</div>
</section>
)
}
function TimelineRelease({
entry,
latest,
highlight,
open,
onToggle,
}: {
entry: ReleaseTimelineEntry
latest: boolean
highlight: boolean
open: boolean
onToggle: () => void
}) {
const {t} = useTranslation()
const hasNotes = Boolean(entry.notes.trim())
return (
<div className='group relative mb-4.5 last:mb-0'>
{/* Timeline dot, centered on the spine and on the version row's text
line (the row is 30px tall, so its center sits at 15px); the newest
release glows and ripples, older dots are opaque so the spine
doesn't show through them */}
<span
aria-hidden
className={cn(
'absolute rounded-full',
latest
? 'top-2 -left-7 h-3.5 w-3.5 bg-white shadow-[0_0_0_1px_rgba(255,255,255,0.25),0_0_0_5px_rgba(255,255,255,0.08)] after:absolute after:inset-0 after:rounded-full after:border after:border-white/60 after:content-[""] motion-safe:after:animate-[umbrel-ripple_2.4s_ease-out_infinite]'
: 'top-2.5 left-[-26px] h-2.5 w-2.5 bg-zinc-600',
)}
/>
<button
className={cn(
'flex min-h-[30px] w-full items-center justify-between gap-4 rounded-8 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-white/20',
!hasNotes && 'pointer-events-none',
)}
onClick={onToggle}
aria-expanded={hasNotes ? open : undefined}
disabled={!hasNotes}
>
<span
className={cn(
'inline-flex min-w-0 items-center gap-2 text-15 font-semibold transition-colors',
hasNotes && 'group-hover:text-brand-lightest',
)}
>
<span className='truncate'>{t('app-page.section.release-notes.version', {version: entry.version})}</span>
{highlight && (
<span className='rounded-full bg-brand/20 px-2 py-0.5 text-11 leading-tight font-medium whitespace-nowrap text-brand-lightest'>
{t('app-store.status.update-available')}
</span>
)}
{hasNotes && (
// A plus built from two strokes: the vertical one rotates flat
// when the entry opens, so + becomes − in place
<span aria-hidden className='relative grid h-[15px] w-[15px] shrink-0 place-items-center text-white/40'>
<span className='absolute h-px w-[9px] rounded-full bg-current' />
<motion.span
initial={false}
animate={{rotate: open ? 90 : 0}}
transition={expandTransition}
className='absolute h-[9px] w-px rounded-full bg-current'
/>
</span>
)}
</span>
<ReleaseDate date={entry.date} />
</button>
{hasNotes ? (
// Notes stay mounted; the reveal animates height on the sheet's
// standard morph curve
<motion.div
initial={false}
animate={{height: open ? 'auto' : 0, opacity: open ? 1 : 0}}
transition={expandTransition}
className='overflow-hidden'
inert={!open}
>
<div className='flex flex-col gap-2.5 pt-2.5'>
{/* Key resets the read-more state when the notes change */}
<ReadMoreMarkdownSection key={entry.notes} className='text-14 text-white/75'>
{entry.notes}
</ReadMoreMarkdownSection>
</div>
</motion.div>
) : (
open && latest && <p className='pt-2 text-13 text-white/30'>{t('app-store.release-timeline.no-notes')}</p>
)}
</div>
)
}
function ReleaseDate({date, className}: {date?: number; className?: string}) {
const [languageCode] = useLanguage()
if (date === undefined) return null
const locale = languageCodeToDateLocale[languageCode]
return (
<time
dateTime={new Date(date).toISOString()}
title={format(date, 'PP', {locale})}
className={cn('text-12 whitespace-nowrap text-white/35', className)}
>
{formatDistanceToNowStrict(date, {addSuffix: true, locale})}
</time>
)
}
@@ -0,0 +1,101 @@
import {motion} from 'motion/react'
import {useEffect, useLayoutEffect, useRef, useState} from 'react'
import {useTranslation} from 'react-i18next'
import {Markdown} from '@/components/markdown'
import {cn} from '@/lib/utils'
import {tw} from '@/utils/tw'
// The store's standard expand/collapse: height animated on the sheet's
// morph curve (see components/ui/shared/motion.ts)
export const expandTransition = {duration: 0.35, ease: [0.32, 0.72, 0, 1]} as const
export const appPageWrapperClass = tw`flex flex-col gap-6 md:gap-8`
// Muted little label above an uncarded page section (timeline, credentials…)
export const appPageSectionLabelClass = tw`text-13 leading-inter-trimmed font-medium text-white/50`
export const appPageTextClass = tw`text-15 leading-relaxed break-words text-white/85`
export function ReadMoreMarkdownSection({
children,
lines = 6,
className,
}: {
children: string
/** How many text lines stay visible while collapsed */
lines?: number
className?: string
}) {
const {t} = useTranslation()
const innerRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [clampedHeight, setClampedHeight] = useState<number | null>(null)
const [isOverflowing, setIsOverflowing] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
// The first commits (mount + pre-paint measurement) must not animate
const mounted = useRef(false)
useEffect(() => {
mounted.current = true
}, [])
const toggle = () => {
setIsExpanded((prev) => !prev)
buttonRef.current?.focus()
}
// The clamp height comes from the real line height (the collapse cut lands
// between lines), and overflow from the unclamped inner content's height
useLayoutEffect(() => {
const inner = innerRef.current
if (!inner) return
/** If the available space is close to enough, don't collapse */
const WIGGLE_ROOM = 20
const measure = () => {
const lineHeight = parseFloat(getComputedStyle(inner).lineHeight) || 24
setClampedHeight(Math.round(lineHeight * lines))
setIsOverflowing(inner.offsetHeight > lineHeight * lines + WIGGLE_ROOM)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(inner)
// Keyboard focus into masked content (links) reveals the rest
const handleFocus = () => setIsExpanded(true)
inner.addEventListener('focusin', handleFocus)
return () => {
observer.disconnect()
inner.removeEventListener('focusin', handleFocus)
}
}, [children, lines])
const collapsed = isOverflowing && !isExpanded
return (
<>
<motion.div
initial={false}
animate={{height: collapsed && clampedHeight !== null ? clampedHeight : 'auto'}}
transition={mounted.current ? expandTransition : {duration: 0}}
style={{
// The last couple of visible lines melt out instead of cutting off
WebkitMaskImage: collapsed ? 'linear-gradient(to bottom, black calc(100% - 3.5em), transparent)' : undefined,
}}
onClick={collapsed ? toggle : undefined}
className={cn(appPageTextClass, 'overflow-hidden', className)}
>
<div ref={innerRef}>
<Markdown>{children}</Markdown>
</div>
</motion.div>
{isOverflowing && (
<button
ref={buttonRef}
onClick={toggle}
className='self-start text-13 font-medium text-white/50 outline-hidden transition-colors hover:text-white focus-visible:text-white'
>
{isExpanded ? t('read-less') : t('read-more')}
</button>
)}
</>
)
}
@@ -0,0 +1,9 @@
import {registryAppPath} from '@/constants/app-store'
import {useStoreActions} from '@/features/app-store/providers/store-actions'
import {AppSettingsDialog} from '@/modules/app-store/app-page/app-settings-dialog'
export function AppSettingsDialogConnected() {
const actions = useStoreActions()
return <AppSettingsDialog onInstallDependency={actions?.installApp} makeDependencyPath={registryAppPath} />
}
@@ -0,0 +1,125 @@
import {compute} from 'compute-scroll-into-view'
import {motion, useReducedMotion} from 'motion/react'
import {useEffect, useId, useRef} from 'react'
import {useTranslation} from 'react-i18next'
import {NavLink, useParams} from 'react-router-dom'
import {FadeScroller} from '@/components/fade-scroller'
import {DarkTooltip} from '@/components/ui/dark-tooltip'
import {categoryIcon, categoryPath, DISCOVER_PATH} from '@/features/app-store/constants'
import {getCategoryLabel, getNavCategories} from '@/features/app-store/data/catalog'
import {useStorefront} from '@/features/app-store/hooks/use-storefront'
import {cn} from '@/lib/utils'
import {useAvailableApps} from '@/providers/available-apps'
import {tw} from '@/utils/tw'
// Horizontally scrollable, keyboard-accessible category rail. Lives in the
// store layout so the active pill morphs smoothly between routes.
export function CategoryRail() {
const {categoryId} = useParams<{categoryId: string}>()
const {appsGroupedByCategory} = useAvailableApps()
const {isUnavailable} = useStorefront()
// No category in the URL means we're on the Discover (index) route
const activeId = categoryId ?? 'discover'
// Discover already ends with the complete catalog, so the All apps pill
// only earns its place when Discover is unavailable (where navigation also
// defaults to it — see discover.tsx)
const navIds = getNavCategories(appsGroupedByCategory ?? {}).filter((navId) => navId !== 'all' || isUnavailable)
return <CategoryRailPills activeId={activeId} navIds={navIds} discoverUnavailable={isUnavailable} />
}
const pillClass = tw`relative flex h-9 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-14 font-medium -tracking-2 whitespace-nowrap outline-hidden transition-colors duration-200`
function CategoryRailPills({
activeId,
navIds,
discoverUnavailable,
}: {
activeId: string
navIds: string[]
discoverUnavailable: boolean
}) {
const {t} = useTranslation()
const layoutId = useId()
const reduceMotion = Boolean(useReducedMotion())
const scrollerRef = useRef<HTMLDivElement>(null)
const activeRef = useRef<HTMLAnchorElement>(null)
// Keep the active pill in view. `scrollIntoView` would also scroll
// `overflow: hidden` ancestors (the sheet), so compute within a boundary.
useEffect(() => {
if (!activeRef.current) return
const actions = compute(activeRef.current, {
scrollMode: 'if-needed',
inline: 'center',
boundary: scrollerRef.current,
})
actions.forEach(({el, top, left}) => {
el.scrollTop = top
el.scrollLeft = left
})
}, [activeId])
return (
<FadeScroller
ref={scrollerRef}
direction='x'
className='umbrel-hide-scrollbar -mx-2.5 flex shrink-0 gap-1 overflow-x-auto px-2.5 py-1 md:mx-0 md:px-0'
>
{navIds.map((navId) => {
const icon = categoryIcon(navId)
const iconImg = icon && (
<img src={icon} alt='' className='relative z-10 h-6 w-6 object-contain' draggable={false} />
)
// Discover is remote editorial content; without it the pill waits,
// disabled, and navigation defaults to All apps instead
if (navId === 'discover' && discoverUnavailable) {
return (
<DarkTooltip key={navId} label={t('app-store.discover-unavailable')} side='bottom'>
<span
aria-disabled
className={cn(pillClass, 'cursor-not-allowed text-white/30 ring-1 ring-white/7 ring-inset')}
>
{iconImg && <span className='opacity-40'>{iconImg}</span>}
<span className='relative z-10 pt-[1px]'>{getCategoryLabel(navId)}</span>
</span>
</DarkTooltip>
)
}
const isActive = navId === activeId
return (
<NavLink
key={navId}
to={navId === 'discover' ? DISCOVER_PATH : categoryPath(navId)}
end
ref={isActive ? activeRef : undefined}
aria-current={isActive ? 'page' : undefined}
className={cn(
pillClass,
isActive
? 'text-white'
: // Inactive pills keep a whisper of an outline so the rail reads
// as a row of controls, not floating labels
'text-white/70 ring-1 ring-white/10 ring-inset hover:text-white hover:ring-white/16 focus-visible:text-white',
'focus-visible:ring-2 focus-visible:ring-white/25',
)}
>
{isActive && (
<motion.span
layoutId={reduceMotion ? undefined : layoutId}
className='settings-edge-material absolute inset-0 rounded-full bg-white/10'
transition={{type: 'spring', bounce: 0.2, duration: 0.4}}
/>
)}
{iconImg}
<span className='relative z-10 pt-[1px]'>{getCategoryLabel(navId)}</span>
</NavLink>
)
})}
</FadeScroller>
)
}
@@ -0,0 +1,204 @@
import {ErrorBoundary} from 'react-error-boundary'
import {useTranslation} from 'react-i18next'
import {Link, Navigate, useParams} from 'react-router-dom'
import {AppIcon} from '@/components/app-icon'
import {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'
import {FadeInImg} from '@/components/ui/fade-in-img'
import {AppCardAction} from '@/features/app-store/components/app-card'
import {AppGrid} from '@/features/app-store/components/app-grid'
import {SortControl, useSortParam} from '@/features/app-store/components/sort-control'
import {
appPath,
categoryIcon,
DISCOVER_PATH,
storeCardClass,
storeRevealClass,
storeRevealDelay,
} from '@/features/app-store/constants'
import type {AppStoreStatus} from '@/features/app-store/data/catalog'
import {getAvailableSorts, getCategoryLabel, sortApps} from '@/features/app-store/data/catalog'
import {preloadFirstFewGalleryImages} from '@/features/app-store/data/gallery-preload'
import {useAppCardStateMap, useAppStatusMap} from '@/features/app-store/hooks/use-app-status'
import {useStorefront} from '@/features/app-store/hooks/use-storefront'
import {useIsSmallMobile} from '@/hooks/use-is-mobile'
import {cn} from '@/lib/utils'
import {useAvailableApps} from '@/providers/available-apps'
import type {AppStateOrLoading, RegistryApp} from '@/trpc/trpc'
export default function Category() {
return (
<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>
<CategoryContent />
</ErrorBoundary>
)
}
function CategoryContent() {
const {t} = useTranslation()
const {categoryId} = useParams<{categoryId: string}>()
const {apps, appsGroupedByCategory, isLoading} = useAvailableApps()
const storefront = useStorefront()
const statuses = useAppStatusMap()
const appStates = useAppCardStateMap(apps ?? [])
const availableSorts = getAvailableSorts(storefront.dates)
const sort = useSortParam(availableSorts)
// Below md the tall featured cards would stack into a wall above the grid;
// dropping them (not just hiding) also skips their gallery image loads
const isSmallMobile = useIsSmallMobile()
if (!categoryId) return null
// Category data is local truth; optional editorial metadata can enhance it
// later but must never hold this route behind a network request.
if (isLoading) return null
// 'all' is the pseudo-category for the complete catalog
const isAll = categoryId === 'all'
const categoryApps = isAll ? (apps ?? []) : (appsGroupedByCategory?.[categoryId] ?? [])
// Unknown category, or one that (no longer) has apps — e.g. a predefined
// category whose apps have all been recategorized upstream
if (categoryApps.length === 0) return <Navigate to={DISCOVER_PATH} replace />
const sortedApps = sortApps(categoryApps, sort, storefront.dates)
const icon = categoryIcon(categoryId)
const featuredApps = isAll || isSmallMobile ? [] : (storefront.featuredByCategory.get(categoryId)?.slice(0, 3) ?? [])
return (
<>
<header className={cn('flex items-center gap-2 md:gap-2.5', storeRevealClass)}>
{icon && <img src={icon} alt='' className='h-14 w-14 object-contain md:h-18 md:w-18' draggable={false} />}
<h2 className='min-w-0 flex-1 truncate text-24 leading-tight font-semibold -tracking-3 md:text-32'>
{getCategoryLabel(categoryId)}
</h2>
</header>
{featuredApps.length > 0 && (
<section
className={cn('flex flex-col gap-3', storeRevealClass)}
style={storeRevealDelay(70)}
aria-label={t('app-store.featured-apps')}
>
<h3 className='truncate px-2.5 text-17 leading-tight font-semibold -tracking-3 md:text-19'>
{t('app-store.featured-apps')}
</h3>
{/* Two columns up to xl, three from there: the third pick only
shows once the row can hold it, never orphaned on its own row */}
<div className='grid gap-2.5 sm:grid-cols-2 xl:grid-cols-3'>
{featuredApps.map((app, index) => {
const actionState = appStates.get(app.id)
return (
<FeaturedAppCard
key={app.id}
app={app}
status={statuses?.get(app.id)}
lifecycleState={actionState?.state}
progress={actionState?.progress}
className={index === 2 ? 'max-xl:hidden' : undefined}
/>
)
})}
</div>
</section>
)}
<section className={cn('flex flex-col gap-3', featuredApps.length > 0 && 'mt-2')}>
{/* Same heading treatment as "Featured apps" above, with the sort
control living on the row it actually sorts */}
<div
className={cn('flex items-center justify-between gap-3 px-2.5', storeRevealClass)}
style={storeRevealDelay(featuredApps.length > 0 ? 130 : 70)}
>
<h3 className='min-w-0 truncate text-17 leading-tight font-semibold -tracking-3 md:text-19'>
{isAll
? t('app-store.section.all-apps')
: t('app-store.all-category-apps', {category: getCategoryLabel(categoryId)})}
</h3>
<SortControl availableSorts={availableSorts} />
</div>
<AppGrid apps={sortedApps} statuses={statuses} revealDelayStart={featuredApps.length > 0 ? 180 : 130} />
</section>
</>
)
}
/**
* A remote-curated category pick: a tall editorial card with the app's first
* screenshot filling the top, its last few pixels melting into the surface
* just above the icon and copy anchored beneath it, and the same working
* action button as the grids. Apps without a gallery get the tiles'
* luminosity-normalized icon glow instead.
*/
function FeaturedAppCard({
app,
status,
lifecycleState,
progress,
className,
}: {
app: RegistryApp
status?: AppStoreStatus
lifecycleState?: AppStateOrLoading
progress?: number
className?: string
}) {
const gallerySrc = app.gallery[0]
return (
<div
onMouseEnter={() => preloadFirstFewGalleryImages(app)}
className={cn(
storeCardClass,
'group relative flex h-[280px] flex-col justify-end overflow-hidden p-4 transition-colors duration-300 hover:bg-white/6',
// Hairline over the card edge — the material's own border sits under
// the artwork, so it needs to be an overlay to show at the top
'after:pointer-events-none after:absolute after:inset-0 after:rounded-24 after:ring-1 after:ring-white/8 after:ring-inset',
className,
)}
>
{/* Backdrop: the first screenshot, ending just above the copy row
(280 - p-4 - 56px icon = 208px). Its bottom third melts into the
surface on an ease-out ramp (front-loaded stops: mostly opaque for
longer, then a soft tail) — a straight linear fade reads as a
hard edge. The row itself sits on plain card. */}
{gallerySrc ? (
<div
aria-hidden
className='absolute inset-x-0 top-0 h-[200px] [mask-image:linear-gradient(to_bottom,black_64%,rgba(0,0,0,0.75)_78%,rgba(0,0,0,0.35)_90%,transparent)]'
>
<FadeInImg
src={gallerySrc}
alt=''
loading='lazy'
className='h-full w-full object-cover object-top opacity-80 transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] group-hover:scale-[1.02]'
/>
</div>
) : (
// Same normalized glow as the rail tiles: the icon's hue over a
// fixed mid-gray luminosity base
<div
aria-hidden
className='absolute top-2 left-1/2 isolate h-24 w-24 -translate-x-1/2 scale-150 opacity-30 blur-xl'
>
<div className='absolute inset-0 bg-neutral-400' />
<img src={app.icon} alt='' className='absolute inset-0 h-full w-full mix-blend-color' draggable={false} />
</div>
)}
{/* Stretched link: the whole card navigates, the action stays independent */}
<Link
to={appPath(app.id)}
state={{fromAppStore: true}}
aria-label={app.name}
className='absolute inset-0 rounded-24 outline-hidden ring-inset focus-visible:ring-2 focus-visible:ring-white/25'
/>
<div className='pointer-events-none relative flex items-center gap-3'>
<AppIcon src={app.icon} size={56} className='shrink-0 rounded-12' />
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<h3 className='truncate text-15 leading-tight font-semibold -tracking-3'>{app.name}</h3>
<p className='line-clamp-2 w-full min-w-0 text-12 leading-tight opacity-40'>{app.tagline}</p>
</div>
<span className='pointer-events-auto'>
<AppCardAction app={app} status={status} lifecycleState={lifecycleState} progress={progress} />
</span>
</div>
</div>
)
}
@@ -0,0 +1,67 @@
import {ErrorBoundary} from 'react-error-boundary'
import {useTranslation} from 'react-i18next'
import {Navigate} from 'react-router-dom'
import {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'
import {AppGrid} from '@/features/app-store/components/app-grid'
import {SectionHeading} from '@/features/app-store/components/section-heading'
import {SortControl, useSortParam} from '@/features/app-store/components/sort-control'
import {StorefrontSectionView} from '@/features/app-store/components/storefront-sections'
import {categoryPath, storeRevealClass, storeRevealDelay} from '@/features/app-store/constants'
import {getAvailableSorts, sortApps} from '@/features/app-store/data/catalog'
import {useAppStatusMap} from '@/features/app-store/hooks/use-app-status'
import {useStorefront} from '@/features/app-store/hooks/use-storefront'
import {useAvailableApps} from '@/providers/available-apps'
// The storefront home: remote editorial sections over the complete local
// catalog. The page waits for the feed's first attempt (see use-storefront.ts)
// so it composes once; there is no spinner and no error state for editorial
// failure — offline the complete catalog is the landing page instead.
export default function Discover() {
return (
<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>
<DiscoverContent />
</ErrorBoundary>
)
}
function DiscoverContent() {
const {t} = useTranslation()
const availableApps = useAvailableApps()
const storefront = useStorefront()
const statuses = useAppStatusMap()
const availableSorts = getAvailableSorts(storefront.dates)
const sort = useSortParam(availableSorts)
if (availableApps.isLoading || storefront.isLoading) return null
// Discover is the editorial home; when the feed is definitively unavailable
// the complete catalog is the better landing page (the rail's Discover pill
// is disabled in that state too)
if (storefront.isUnavailable) return <Navigate to={categoryPath('all')} replace />
const allApps = sortApps(availableApps.apps ?? [], sort, storefront.dates)
// Sections compose in top to bottom, each a beat behind the one before
const sectionDelay = (index: number) => storeRevealDelay(Math.min(90 + index * 70, 440))
return (
<>
{storefront.sections.map((section, index) => (
<div key={section.id} className={storeRevealClass} style={sectionDelay(index)}>
<StorefrontSectionView section={section} statuses={statuses} />
</div>
))}
{/* The complete catalog sits directly on the sheet, not in a card */}
<section className='flex flex-col gap-4'>
<div className={storeRevealClass} style={storeRevealDelay(90)}>
<SectionHeading
title={t('app-store.section.all-apps')}
rightChildren={<SortControl availableSorts={availableSorts} />}
/>
</div>
<AppGrid apps={allApps} statuses={statuses} revealDelayStart={130} />
</section>
</>
)
}
@@ -0,0 +1,51 @@
import {useTranslation} from 'react-i18next'
import {AppGrid, AppStoreEmptyState} from '@/features/app-store/components/app-grid'
import {SectionHeading} from '@/features/app-store/components/section-heading'
import {storeRevealClass, storeRevealDelay} from '@/features/app-store/constants'
import {createAppStoreSearch} from '@/features/app-store/data/search'
import {useAppStatusMap} from '@/features/app-store/hooks/use-app-status'
import {cn} from '@/lib/utils'
import {useAvailableApps} from '@/providers/available-apps'
// Search is local and instant: registry fields only, no network requests.
export function SearchResults({query}: {query: string}) {
const {t} = useTranslation()
const {apps, isLoading} = useAvailableApps()
const statuses = useAppStatusMap()
if (isLoading) return null
const search = createAppStoreSearch(apps ?? [])
const results = search(query)
// Results sit directly on the sheet like category pages — no card container
return (
<div className='flex flex-col gap-4'>
<div className={cn('px-2.5 md:px-0', storeRevealClass)}>
<SectionHeading
title={
<>
<span className='opacity-50'>{t('app-store.search.results-for')}</span> {query}
</>
}
rightChildren={
<span className='text-13 whitespace-nowrap text-white/40'>
{t('app-store.search.result-count', {count: results.length})}
</span>
}
/>
</div>
{results.length > 0 ? (
<AppGrid apps={results} statuses={statuses} revealDelayStart={60} />
) : (
<div className={storeRevealClass} style={storeRevealDelay(60)}>
<AppStoreEmptyState
title={t('app-store.search.no-results')}
description={t('app-store.search.no-results-description')}
/>
</div>
)}
</div>
)
}
@@ -0,0 +1,27 @@
import {ReactNode} from 'react'
import {cn} from '@/lib/utils'
export function SectionHeading({
overline,
title,
rightChildren,
className,
}: {
overline?: string
title: ReactNode
rightChildren?: ReactNode
className?: string
}) {
return (
<div className={cn('flex items-end justify-between gap-3 px-2.5', className)}>
<div className='flex min-w-0 flex-col gap-1'>
{overline && (
<p className='text-11 leading-tight font-semibold tracking-wide uppercase opacity-40'>{overline}</p>
)}
<h2 className='truncate text-17 leading-tight font-semibold -tracking-3 md:text-19'>{title}</h2>
</div>
{rightChildren && <div className='flex shrink-0 items-center gap-2'>{rightChildren}</div>}
</div>
)
}
@@ -0,0 +1,65 @@
import {useTranslation} from 'react-i18next'
import {useSearchParams} from 'react-router-dom'
import {arrayIncludes} from 'ts-extras'
import {ChevronDown} from '@/components/chevron-down'
import {Button} from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type {AppSortId} from '@/features/app-store/data/catalog'
/**
* Sort choice backed by `?sort=` so it survives back-navigation. Unavailable
* options (e.g. date sorts while offline) are hidden, not disabled; with only
* one option there is nothing to choose, so nothing renders.
*/
export function useSortParam(availableSorts: AppSortId[]): AppSortId {
const [searchParams] = useSearchParams()
const requested = searchParams.get('sort')
return arrayIncludes(availableSorts, requested) ? requested : 'name'
}
export function SortControl({availableSorts}: {availableSorts: AppSortId[]}) {
const {t} = useTranslation()
const [searchParams, setSearchParams] = useSearchParams()
const sort = useSortParam(availableSorts)
if (availableSorts.length < 2) return null
const labels: Record<AppSortId, string> = {
name: t('app-store.sort.name'),
newest: t('app-store.sort.newest'),
'recently-updated': t('app-store.sort.recently-updated'),
}
const selectSort = (value: AppSortId) => {
if (value === 'name') searchParams.delete('sort')
else searchParams.set('sort', value)
setSearchParams(searchParams, {replace: true})
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size='md' className='group w-auto gap-2' aria-label={t('app-store.sort.label')}>
{labels[sort]}
<span className='transition-transform duration-200 group-data-[state=open]:rotate-180'>
<ChevronDown />
</span>
</Button>
</DropdownMenuTrigger>
{/* p-1 matches the tight context-menu surface */}
<DropdownMenuContent className='p-1' align='end'>
{availableSorts.map((id) => (
<DropdownMenuCheckboxItem key={id} checked={sort === id} onSelect={() => selectSort(id)}>
{labels[id]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,260 @@
import {motion, useTransform} from 'motion/react'
import {useLayoutEffect, useRef, useState} from 'react'
import {useTranslation} from 'react-i18next'
import {RiCloseCircleFill} from 'react-icons/ri'
import {TbCircleArrowUp, TbDots, TbSearch} from 'react-icons/tb'
import {Link} from 'react-router-dom'
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'
import {NotificationBadge} from '@/components/ui/notification-badge'
import {SheetHeader, SheetTitle} from '@/components/ui/sheet'
import {CategoryRail} from '@/features/app-store/components/category-rail'
import {UpdatesDialogConnected} from '@/features/app-store/components/updates-dialog'
import {storeRevealSoftClass} from '@/features/app-store/constants'
import {useStickyCollapse} from '@/features/app-store/hooks/use-sticky-collapse'
import type {useStoreSearch} from '@/features/app-store/hooks/use-store-search'
import {useAppsWithUpdates} from '@/hooks/use-apps-with-updates'
import {useIsMobile} from '@/hooks/use-is-mobile'
import {useQueryParams} from '@/hooks/use-query-params'
import {cn} from '@/lib/utils'
import {CommunityAppStoreDialog} from '@/modules/app-store/community-app-store-dialog'
import {useSheetStickyHeader} from '@/providers/sheet-sticky-header'
import {useLinkToDialog} from '@/utils/dialog'
type StoreSearch = ReturnType<typeof useStoreSearch>
/**
* The store's chrome: title, search, owner controls, and the category rail.
* On desktop it pins to the top of the sheet and collapses with the scroll,
* exactly like the app page hero: the title dissolves, search and the owner
* controls settle onto the bar's centerline, and the category rail rises onto
* the same line, shrinking to the space left beside them while staying
* horizontally scrollable — then the blurred bar surface pops in underneath.
*/
export function StoreHeader({search, isOwner}: {search: StoreSearch; isOwner: boolean}) {
const isMobile = useIsMobile()
return isMobile ? (
<MobileStoreHeader search={search} isOwner={isOwner} />
) : (
<DesktopStoreHeader search={search} isOwner={isOwner} />
)
}
// Geometry of the collapse (px, desktop): title row, a gap, then the rail —
// all interpolating into one 64px bar (the same height as the app page hero
// bar, dictated by the sheet close button's centerline at 32px).
const TITLE_ROW = 36
const ROW_GAP = 20
const RAIL_HEIGHT = 44 // h-9 pills + the rail's own py-1
const HEADER_HEIGHT = TITLE_ROW + ROW_GAP + RAIL_HEIGHT
const BAR_HEIGHT = 64
const COLLAPSE_DISTANCE = 90 // scroll depth that scrubs the collapse
function DesktopStoreHeader({search, isOwner}: {search: StoreSearch; isOwner: boolean}) {
const {t} = useTranslation()
const {scrollRef} = useSheetStickyHeader()
// 0 = full header, 1 = fully collapsed bar
const wrapperRef = useRef<HTMLDivElement>(null)
const progress = useStickyCollapse(wrapperRef, COLLAPSE_DISTANCE)
// The rail shrinks to the space left beside the controls, so their width
// (which varies with owner chips and the updates badge) is measured live
const clusterRef = useRef<HTMLDivElement>(null)
const [clusterWidth, setClusterWidth] = useState(300)
useLayoutEffect(() => {
const cluster = clusterRef.current
if (!cluster) return
const measure = () => setClusterWidth(cluster.getBoundingClientRect().width)
measure()
const observer = new ResizeObserver(measure)
observer.observe(cluster)
return () => observer.disconnect()
}, [])
// The title dissolves early with a whisper of drift
const titleOpacity = useTransform(progress, [0, 0.45], [1, 0])
const titleY = useTransform(progress, [0, 1], [0, -8])
// Search and the owner controls settle onto the bar's centerline
const clusterY = useTransform(progress, [0, 1], [0, (BAR_HEIGHT - 36) / 2])
// The rail rises onto the same line, its right edge pulling in to leave
// room for the controls
const railY = useTransform(progress, [0, 1], [TITLE_ROW + ROW_GAP, (BAR_HEIGHT - RAIL_HEIGHT) / 2])
const railRight = useTransform(progress, [0, 1], [0, clusterWidth + 16])
// The bar surface pops in only once everything is in its final position;
// while visible, clicking its empty area scrolls back to the top
const surfaceOpacity = useTransform(progress, [0.9, 1], [0, 1])
const surfacePointerEvents = useTransform(progress, (p) => (p > 0.999 ? 'auto' : 'none'))
// While searching the rail hides and the header is just the title row
const showRail = !search.deferredQuery
const headerHeight = showRail ? HEADER_HEIGHT : TITLE_ROW
return (
<div
ref={wrapperRef}
data-sticky-chrome
className={cn('pointer-events-none sticky top-0 z-40', storeRevealSoftClass)}
style={{height: headerHeight}}
>
{/* The solid bar surface, bleeding through the sheet's padding to
its edges — pops in once the collapse completes (see app-hero.tsx
for why it isn't frosted) */}
<motion.div
aria-hidden
style={{
opacity: surfaceOpacity,
pointerEvents: surfacePointerEvents,
height: BAR_HEIGHT,
boxShadow: '2px 2px 2px 0px #FFFFFF0D inset',
}}
onClick={() => scrollRef?.current?.scrollTo({top: 0, behavior: 'smooth'})}
className='umbrel-window-surface-top absolute -inset-x-3 top-0 border-b border-white/10 bg-black md:-inset-x-[40px] xl:-inset-x-[70px]'
/>
<motion.div style={{opacity: titleOpacity, y: titleY}} className='absolute top-0 left-0'>
{/* lg:text-36 matches the Settings sheet title */}
<SheetTitle className='leading-none whitespace-nowrap lg:text-36'>{t('app-store.title')}</SheetTitle>
</motion.div>
<motion.div
ref={clusterRef}
style={{y: clusterY}}
className='pointer-events-auto absolute top-0 right-0 flex items-center gap-2'
>
<StoreSearchInput inputRef={search.inputRef} value={search.query} onValueChange={search.setQuery} />
{isOwner && <UpdatesChip />}
{isOwner && <CommunityAppsMenu />}
</motion.div>
{showRail && (
<motion.div style={{y: railY, right: railRight}} className='pointer-events-auto absolute left-0'>
<CategoryRail />
</motion.div>
)}
</div>
)
}
function MobileStoreHeader({search, isOwner}: {search: StoreSearch; isOwner: boolean}) {
const {t} = useTranslation()
return (
<SheetHeader className={cn('gap-4', storeRevealSoftClass)}>
<div className='flex flex-wrap items-center gap-x-3 gap-y-3 px-2.5'>
<SheetTitle className='leading-none whitespace-nowrap'>{t('app-store.title')}</SheetTitle>
<div className='flex min-w-0 flex-1 items-center justify-end gap-2'>
<StoreSearchInput inputRef={search.inputRef} value={search.query} onValueChange={search.setQuery} />
{isOwner && <UpdatesChip />}
{isOwner && <CommunityAppsMenu />}
</div>
</div>
{!search.deferredQuery && <CategoryRail />}
</SheetHeader>
)
}
function StoreSearchInput({
value,
onValueChange,
inputRef,
}: {
value: string
onValueChange: (query: string) => void
inputRef?: React.Ref<HTMLInputElement>
}) {
const {t} = useTranslation()
return (
<div
className={cn(
'settings-edge-material flex h-9 w-full max-w-[200px] min-w-0 shrink items-center gap-2 rounded-full bg-white/3 px-3 text-white/55 transition-colors duration-200',
'focus-within:bg-white/7 focus-within:text-white/80 hover:bg-white/6',
)}
>
<TbSearch className='h-4 w-4 shrink-0 opacity-70' />
<input
ref={inputRef}
className='min-w-0 flex-1 bg-transparent text-13 text-white outline-hidden placeholder:text-white/35'
placeholder={t('app-store.search-apps')}
value={value}
onChange={(e) => onValueChange(e.target.value)}
// Two-stage Escape: first clears the query, second blurs the input
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (value) {
onValueChange('')
e.preventDefault()
} else {
e.currentTarget.blur()
e.preventDefault()
}
}
}}
/>
{value && (
<button
aria-label={t('app-store.search.clear')}
// Keep focus in the input so clearing doesn't end the search session
onPointerDown={(e) => e.preventDefault()}
onClick={() => onValueChange('')}
className='shrink-0 opacity-50 outline-hidden transition-opacity hover:opacity-90 focus-visible:opacity-90'
>
<RiCloseCircleFill className='h-4 w-4' />
</button>
)}
</div>
)
}
// Compact global shortcut to the updates dialog. The prominent updates shelf
// on Discover is the primary surface; this chip keeps updates reachable from
// category pages and search too.
function UpdatesChip() {
const {t} = useTranslation()
const linkToDialog = useLinkToDialog()
const {appsWithUpdates, isLoading} = useAppsWithUpdates()
if (isLoading || appsWithUpdates.length === 0) {
// Keep the dialog mounted so ?dialog=updates deep links keep working
return <UpdatesDialogConnected />
}
return (
<>
<Link
to={linkToDialog('updates')}
aria-label={t('app-updates.updates-available-count', {count: appsWithUpdates.length})}
className='settings-edge-material relative flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-white/3 px-3 text-13 font-medium text-white/70 outline-hidden transition-colors duration-200 hover:bg-white/6 hover:text-white focus-visible:ring-2 focus-visible:ring-white/25'
>
<TbCircleArrowUp className='h-4 w-4' />
<span className='max-md:hidden'>{t('app-store.updates')}</span>
<NotificationBadge count={appsWithUpdates.length} />
</Link>
<UpdatesDialogConnected />
</>
)
}
function CommunityAppsMenu() {
const {t} = useTranslation()
const {addLinkSearchParams} = useQueryParams()
return (
<>
<DropdownMenu>
{/* tabIndex={-1} because we want the user to be able to tab straight to results */}
<DropdownMenuTrigger
tabIndex={-1}
className='settings-edge-material flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/3 text-white/70 transition-colors duration-200 hover:bg-white/6 hover:text-white'
aria-label={t('app-store.menu.community-app-stores')}
>
<TbDots className='h-4.5 w-4.5' />
</DropdownMenuTrigger>
{/* p-1 matches the tight context-menu surface */}
<DropdownMenuContent className='p-1' align='end'>
<DropdownMenuItem asChild>
<Link to={{search: addLinkSearchParams({dialog: 'add-community-store'})}}>
{t('app-store.menu.community-app-stores')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CommunityAppStoreDialog />
</>
)
}
@@ -0,0 +1,283 @@
import {useState} from 'react'
import {useTranslation} from 'react-i18next'
import {TbChevronRight} from 'react-icons/tb'
import {Link} from 'react-router-dom'
import {FadeScroller} from '@/components/fade-scroller'
import {
Carousel,
CarouselArrows,
CarouselContent,
CarouselDots,
CarouselItem,
useCarouselAutoAdvance,
useCarouselSnaps,
type CarouselApi,
} from '@/components/ui/carousel'
import {FadeInImg} from '@/components/ui/fade-in-img'
import {AppCardCompact, AppChip} from '@/features/app-store/components/app-card'
import {AppGridSection} from '@/features/app-store/components/app-grid'
import {SectionHeading} from '@/features/app-store/components/section-heading'
import {appPath, categoryPath, sheetBleedClass, storeCardClass} from '@/features/app-store/constants'
import type {AppStoreStatus} from '@/features/app-store/data/catalog'
import {getCategoryLabel} from '@/features/app-store/data/catalog'
import {preloadFirstFewGalleryImages} from '@/features/app-store/data/gallery-preload'
import type {ResolvedSection, SpotlightBanner as SpotlightBannerData} from '@/features/app-store/data/storefront'
import {cn} from '@/lib/utils'
import type {RegistryApp} from '@/trpc/trpc'
// Renders one resolved editorial section. Sections fade in when remote data
// arrives, enhancing the already-complete local layout underneath.
export function StorefrontSectionView({
section,
statuses,
}: {
section: ResolvedSection
statuses?: Map<string, AppStoreStatus>
}) {
switch (section.type) {
case 'spotlight':
return <SpotlightCarousel banners={section.banners} />
case 'app-list':
return section.layout === 'rail' ? (
<AppRailSection overline={section.subtitle} title={section.title} apps={section.apps} statuses={statuses} />
) : (
<AppGridSection overline={section.subtitle} title={section.title} apps={section.apps} statuses={statuses} />
)
case 'category-feature':
return (
<CategoryFeatureSection
categoryId={section.categoryId}
title={section.title}
description={section.description}
apps={section.apps}
artwork={section.artwork}
textSide={section.textSide}
/>
)
}
}
/**
* The spotlight section's banners in one row, like the apps.umbrel.com
* banner carousel: the active banner snaps centered while its neighbors peek
* in from the sheet edges, auto-rotating on a 5s timer with the countdown
* filling the active dot. Dragging or picking a dot restarts the clock. A
* banner whose artwork fails to load simply leaves the row — the artwork is
* the whole banner, there is nothing else to show in its place.
*/
export function SpotlightCarousel({banners}: {banners: SpotlightBannerData[]}) {
const [api, setApi] = useState<CarouselApi>()
const [failedAppIds, setFailedAppIds] = useState<ReadonlySet<string>>(new Set())
const [hovered, setHovered] = useState(false)
const [focusWithin, setFocusWithin] = useState(false)
const shown = banners.filter((banner) => !failedAppIds.has(banner.app.id))
const {snapCount, activeIndex} = useCarouselSnaps(api)
const progress = useCarouselAutoAdvance(api, {enabled: shown.length > 1, paused: hovered || focusWithin})
const onArtworkError = (appId: string) => setFailedAppIds((prev) => new Set(prev).add(appId))
if (shown.length === 0) return null
if (shown.length === 1) return <SpotlightBanner banner={shown[0]} onArtworkError={onArtworkError} />
return (
<section
aria-label={shown.map((banner) => banner.app.name).join(' · ')}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocusCapture={() => setFocusWithin(true)}
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) setFocusWithin(false)
}}
>
<Carousel
setApi={setApi}
className='-mx-3 md:-mx-[40px] xl:-mx-[70px]'
opts={{align: 'center', containScroll: 'trimSnaps'}}
>
<CarouselContent containerClassName='px-3 md:px-[40px] xl:px-[70px]' className='-ml-3 md:-ml-4'>
{shown.map((banner) => (
<CarouselItem key={banner.app.id} className='basis-[86%] pl-3 md:basis-[78%] md:pl-4'>
<SpotlightBanner banner={banner} onArtworkError={onArtworkError} />
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div className='mt-3 flex items-center justify-between'>
<CarouselDots
count={snapCount}
activeIndex={activeIndex}
onSelect={(index) => api?.scrollTo(index)}
progress={progress}
/>
{/* Arrows wrap around the ends, like the apps.umbrel.com banners */}
<CarouselArrows
onPrev={() => api?.scrollTo((activeIndex - 1 + snapCount) % snapCount)}
onNext={() => api?.scrollTo((activeIndex + 1) % snapCount)}
/>
</div>
</section>
)
}
export function AppRailSection({
overline,
title,
apps,
statuses,
}: {
overline?: string
title: string
apps: RegistryApp[]
statuses?: Map<string, AppStoreStatus>
}) {
return (
<section className='flex flex-col gap-3'>
<SectionHeading overline={overline} title={title} />
{/* overflow-y-hidden because overflow-x:auto would otherwise force the
vertical axis to auto too, making any 1px of decoration scrollable */}
<FadeScroller
direction='x'
className={cn('umbrel-hide-scrollbar flex gap-2.5 overflow-x-auto overflow-y-hidden py-1', sheetBleedClass)}
>
{apps.map((app) => (
<AppCardCompact key={app.id} app={app} status={statuses?.get(app.id)} />
))}
</FadeScroller>
</section>
)
}
function SpotlightBanner({
banner: {app, artwork},
onArtworkError,
}: {
banner: SpotlightBannerData
onArtworkError: (appId: string) => void
}) {
const {t} = useTranslation()
// The artwork is a complete editorial composition (icon, name and headline
// are part of the image), so it renders untouched and the banner is just a
// link to the app it shows
return (
<Link
to={appPath(app.id)}
state={{fromAppStore: true}}
onMouseEnter={() => preloadFirstFewGalleryImages(app)}
aria-label={app.name}
className={cn(
storeCardClass,
'group relative block overflow-hidden outline-hidden focus-visible:ring-2 focus-visible:ring-white/25',
)}
>
<div className='relative aspect-[2/1] w-full'>
<FadeInImg
src={artwork.dark}
alt=''
loading='lazy'
className='absolute inset-0 h-full w-full object-cover transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] group-hover:scale-[1.015]'
onError={() => onArtworkError(app.id)}
/>
<span className='absolute right-3 bottom-3 flex items-center gap-0.5 rounded-full bg-black/40 py-1.5 pr-2 pl-3 text-12 font-medium text-white/90 backdrop-blur-md transition-colors duration-200 group-hover:bg-black/60 max-md:hidden md:right-5 md:bottom-5'>
{t('app.view')}
<TbChevronRight className='h-3.5 w-3.5' />
</span>
</div>
</Link>
)
}
function CategoryFeatureSection({
categoryId,
title,
description,
apps,
artwork,
textSide,
}: {
categoryId: string
title: string
description: string
apps: RegistryApp[]
artwork: {dark: string}
textSide: 'left' | 'right'
}) {
const {t} = useTranslation()
const [artworkFailed, setArtworkFailed] = useState(false)
return (
<section className={cn(storeCardClass, 'relative overflow-hidden')}>
{/* Desktop: the artwork fills the card and the copy sits on its clear
side. Mobile: the artwork is the backdrop of the card's top —
cropped to its busy side (the icon cluster opposite the clear
side) and scaled to the area's height so the icons stay large —
melting into the surface through an eased mask; the copy then
sits over that fade at the bottom, so nothing is ever legible
only by luck. */}
<div
className={cn(
'absolute inset-x-0 top-0 h-[280px]',
'max-md:[mask-image:linear-gradient(to_bottom,black_50%,rgba(0,0,0,0.6)_74%,rgba(0,0,0,0.2)_88%,transparent_96%)]',
'md:inset-0 md:h-auto',
)}
>
{!artworkFailed && (
<FadeInImg
src={artwork.dark}
alt=''
loading='lazy'
className={cn(
'absolute inset-0 h-full w-full object-cover',
textSide === 'left' ? 'max-md:object-[72%_50%]' : 'max-md:object-[28%_50%]',
)}
onError={() => setArtworkFailed(true)}
/>
)}
{artworkFailed && <div className='absolute inset-0 bg-linear-to-br from-white/10 to-white/2' />}
</div>
{/* On mobile the copy mirrors the artwork: icons cropped to the left
mean the text, chips and link anchor to the right, and vice
versa. On desktop it's a side column, read left-aligned either way. */}
<div
className={cn(
'relative flex flex-col gap-3 p-4 max-md:pt-[216px] md:min-h-[280px] md:w-1/2 md:justify-center md:gap-4 md:p-8 lg:min-h-[320px]',
textSide === 'right' && 'max-md:items-end max-md:text-right md:ml-auto',
)}
>
<div className='flex flex-col gap-1.5'>
<h2 className='text-20 leading-tight font-semibold -tracking-3 md:text-24'>{title}</h2>
<p className='max-w-md text-13 leading-snug opacity-60'>{description}</p>
</div>
{/* A right-anchored rail still has to scroll from its start when the
chips overflow, so the first chip carries an auto left margin
(right-aligns while they fit, collapses to zero once they don't)
instead of justify-end, which would clip the leading chips */}
<FadeScroller
direction='x'
className={cn(
// Pulled left by the chips' own inner padding (pl-1.5) so the first
// icon lines up with the headline instead of sitting 6px in
'umbrel-hide-scrollbar -mx-4 flex gap-1.5 overflow-x-auto pr-4 pl-2.5 md:mr-0 md:-ml-1.5 md:flex-wrap md:overflow-visible md:px-0',
textSide === 'right' && 'max-md:self-stretch max-md:[&>*:first-child]:ml-auto',
)}
>
{apps.slice(0, 6).map((app) => (
<AppChip key={app.id} app={app} />
))}
</FadeScroller>
<Link
to={categoryPath(categoryId)}
className={cn(
'flex items-center gap-0.5 self-start text-13 font-medium text-white/70 outline-hidden transition-colors duration-200 hover:text-white focus-visible:text-white',
textSide === 'right' && 'max-md:self-end',
)}
>
{t('app-store.browse-category-apps', {category: getCategoryLabel(categoryId)})}
<TbChevronRight className='h-3.5 w-3.5' />
</Link>
</div>
</section>
)
}
@@ -4,18 +4,20 @@ import {useTranslation} from 'react-i18next'
import {arrayIncludes} from 'ts-extras'
import {AppIcon} from '@/components/app-icon'
import {appStateToString} from '@/components/cmdk'
import {Markdown} from '@/components/markdown'
import {ProgressButton} from '@/components/progress-button'
import {Button} from '@/components/ui/button'
import {Dialog, DialogContent, DialogHeader, DialogPortal, DialogTitle} from '@/components/ui/dialog'
import {ScrollArea} from '@/components/ui/scroll-area'
import {Separator} from '@/components/ui/separator'
import {pollStates, useAppInstallProgress} from '@/hooks/use-app-install'
import {useAppsWithUpdates} from '@/hooks/use-apps-with-updates'
import {useUpdateAllApps} from '@/hooks/use-update-all-apps'
import {useUpdateApp} from '@/hooks/use-update-app'
import {cn} from '@/lib/utils'
import {progressStates, RegistryApp, trpcReact} from '@/trpc/trpc'
import {MS_PER_SECOND} from '@/utils/date-time'
import {appStateToString} from '@/modules/app-store/app-state-strings'
import {canExecuteUpdate} from '@/modules/app-store/update-availability'
import {RegistryApp} from '@/trpc/trpc'
import {useDialogOpenProps} from '@/utils/dialog'
export function UpdatesDialogConnected() {
@@ -23,7 +25,6 @@ export function UpdatesDialogConnected() {
const dialogProps = useDialogOpenProps('updates')
const {appsWithUpdates, isLoading} = useAppsWithUpdates()
const updateAll = useUpdateAllApps()
const compatibleUpdates = appsWithUpdates.filter((app) => app.compatible)
if (isLoading) return null
@@ -32,16 +33,20 @@ export function UpdatesDialogConnected() {
{...dialogProps}
open={dialogProps.open}
appsWithUpdates={appsWithUpdates}
// A single update has its own row button; Update all only earns its
// place once there is more than one
titleRightChildren={
<Button
size='md'
variant='primary'
onClick={updateAll.updateAll}
className='w-auto'
disabled={updateAll.isLoading || updateAll.isUpdating || compatibleUpdates.length === 0}
>
{updateAll.isUpdating ? t('app-updates.updating') : t('app-updates.update-all')}
</Button>
appsWithUpdates.length > 1 && (
<Button
size='md'
variant='primary'
onClick={updateAll.updateAll}
className='w-auto'
disabled={updateAll.isLoading || !updateAll.canUpdateAll}
>
{updateAll.isUpdating ? t('app-updates.updating') : t('app-updates.update-all')}
</Button>
)
}
/>
)
@@ -60,7 +65,7 @@ export function UpdatesDialog({
<Dialog {...dialogProps}>
<DialogPortal>
<DialogContent
className='top-[10%] max-h-[calc(100vh-20%)] translate-y-0 gap-0 p-0 py-5 data-[state=closed]:slide-out-to-top-[0%] data-[state=open]:slide-in-from-top-[0%]'
className='umbrel-app-store-modal top-[10%] max-h-[calc(100vh-20%)] translate-y-0 gap-0 p-0 py-5 data-[state=closed]:slide-out-to-top-[0%] data-[state=open]:slide-in-from-top-[0%]'
slide={false}
>
<DialogHeader className='px-5 pb-5'>
@@ -88,29 +93,13 @@ export function UpdatesDialog({
}
function AppItem({app}: {app: RegistryApp}) {
const {t} = useTranslation()
const appStateQ = trpcReact.apps.state.useQuery(
{appId: app.id},
{
refetchInterval: 2 * MS_PER_SECOND,
},
)
const [showAll, setShowAll] = useState(false)
const utils = trpcReact.useUtils()
const updateMut = trpcReact.apps.update.useMutation({
onMutate: () => {
// Optimistic updates because otherwise it's too slow and feels like nothing is happening
utils.apps.state.cancel()
utils.apps.state.setData({appId: app.id}, {state: 'updating', progress: 0})
},
onSuccess: () => {
// This should cause the app to be removed from the list
utils.apps.list.invalidate()
},
})
// Polls the per-app state only while a transition is running, so an open
// dialog with many rows doesn't poll for idle apps
const {state, progress} = useAppInstallProgress(app.id)
const updateApp = useUpdateApp(app.id)
const progress = appStateQ.data?.progress
const appState = appStateQ.isLoading ? 'loading' : appStateQ.data!.state
const inProgress = arrayIncludes(progressStates, appState)
const inProgress = state !== 'loading' && arrayIncludes(pollStates, state)
return (
<div className='p-2.5'>
@@ -130,15 +119,15 @@ function AppItem({app}: {app: RegistryApp}) {
<div className='flex-1' />
<ProgressButton
size='sm'
onClick={() => updateMut.mutate({appId: app.id})}
disabled={!app.compatible || inProgress || updateMut.isPending}
state={appState}
onClick={updateApp.update}
disabled={!canExecuteUpdate(state, app.compatible) || updateApp.isPending}
state={state}
progress={progress}
style={{
['--progress-button-bg' as string]: 'hsl(0 0 30%)',
}}
>
{inProgress ? appStateToString(appState, t) + '...' : t('app-updates.update')}
{inProgress ? appStateToString(state, t) + '...' : t('app-updates.update')}
</ProgressButton>
</div>
{app.releaseNotes && (
@@ -0,0 +1,88 @@
import {useTranslation} from 'react-i18next'
import {TbCircleArrowUp} from 'react-icons/tb'
import {Link} from 'react-router-dom'
import {AppIcon} from '@/components/app-icon'
import {buttonVariants} from '@/components/ui/button'
import {storeCardClass, storeRevealClass, storeRevealDelay} from '@/features/app-store/constants'
import {useAppsWithUpdates} from '@/hooks/use-apps-with-updates'
import {cn} from '@/lib/utils'
import {trpcReact} from '@/trpc/trpc'
import {useLinkToDialog} from '@/utils/dialog'
// Maintenance before discovery: when updates exist, the storefront leads with
// this compact shelf. Members browse read-only, so they never see it.
const MAX_SHELF_ICONS = 3
export function UpdatesShelf() {
const {t} = useTranslation()
const linkToDialog = useLinkToDialog()
const isOwner = trpcReact.user.get.useQuery().data?.role === 'owner'
const {appsWithUpdates, updatableApps, updatingApps, isLoading} = useAppsWithUpdates()
if (!isOwner || isLoading || appsWithUpdates.length === 0) return null
const compatibleUpdates = appsWithUpdates.filter((app) => app.compatible)
const incompatibleCount = appsWithUpdates.length - compatibleUpdates.length
const updatingOnly = updatableApps.length === 0 && updatingApps.length > 0
const shelfLabel = updatingOnly
? t('app-updates.updating')
: t('app-updates.updates-available-count', {count: appsWithUpdates.length})
return (
// The whole shelf is one link to the updates dialog (where individual
// updates and Update all live); the "View" at the end is just its visual
// affordance — an <a> can't hold a nested interactive control
<Link
to={linkToDialog('updates')}
className={cn(
storeCardClass,
'group flex items-center gap-1.5 p-2 outline-hidden transition-colors duration-200 hover:bg-white/6 focus-visible:ring-2 focus-visible:ring-white/25 md:gap-4 md:p-5',
storeRevealClass,
)}
style={storeRevealDelay(50)}
aria-label={shelfLabel}
>
{/* The first few icons fanned left-to-right, first on top; the overlap
reads as depth through a soft shadow — the icons carry their own
border already, so no ring */}
<div className='flex shrink-0 -space-x-6 md:-space-x-2.5'>
{appsWithUpdates.slice(0, MAX_SHELF_ICONS).map((app, index) => (
<AppIcon
key={app.id}
src={app.icon}
className='size-9 rounded-10 shadow-[0_2px_10px_rgba(0,0,0,0.5)] md:size-10'
style={{zIndex: MAX_SHELF_ICONS - index}}
/>
))}
</div>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<h2 className='flex items-center gap-1 truncate text-14 leading-tight font-semibold -tracking-3 sm:gap-1.5 sm:text-15'>
<TbCircleArrowUp className='h-4.5 w-4.5 shrink-0 text-brand-lightest' />
{shelfLabel}
</h2>
{/* Names beyond the fanned icons collapse into "…, and more" */}
<p className='truncate text-13 leading-tight text-white/40'>
{appsWithUpdates.length > MAX_SHELF_ICONS
? t('app-store.updates-shelf.names-and-more', {
names: appsWithUpdates
.slice(0, MAX_SHELF_ICONS)
.map((app) => app.name)
.join(', '),
})
: appsWithUpdates.map((app) => app.name).join(', ')}
</p>
{incompatibleCount > 0 && (
<p className='text-12 leading-tight text-amber-300/70'>
{t('app-store.updates-shelf.os-update-required-count', {count: incompatibleCount})}
</p>
)}
</div>
<span
className={cn(buttonVariants({size: 'md', variant: 'primary'}), 'w-auto shrink-0 group-hover:bg-brand-lighter')}
>
{t('app.view')}
</span>
</Link>
)
}
@@ -0,0 +1,112 @@
import {t} from '@/utils/i18n'
import {tw} from '@/utils/tw'
export {appPath, UMBREL_APP_STORE_ID} from '@/constants/app-store'
// ---------------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------------
// The category vocabulary app manifests can use. Apps may also declare
// categories umbrelOS doesn't know about yet; those are handled dynamically.
export const categories = [
'files',
'bitcoin',
'media',
'networking',
'social',
'automation',
'finance',
'ai',
'developer',
'crypto',
] as const
export type Category = (typeof categories)[number]
// Category order in the navigation rail, mirroring apps.umbrel.com. The rail
// shows the Discover and All apps destinations ahead of these.
export const categoryNavOrder: readonly Category[] = [
'files',
'ai',
'bitcoin',
'media',
'finance',
'networking',
'automation',
'social',
'developer',
'crypto',
]
// Labels stay literal t() calls so the translation updater can find the keys
export const categoryLabels: Record<'discover' | 'all' | Category, () => string> = {
discover: () => t('app-store.category.discover'),
all: () => t('app-store.category.all'),
files: () => t('app-store.category.files'),
ai: () => t('app-store.category.ai'),
bitcoin: () => t('app-store.category.bitcoin'),
media: () => t('app-store.category.media'),
finance: () => t('app-store.category.finance'),
networking: () => t('app-store.category.networking'),
automation: () => t('app-store.category.automation'),
social: () => t('app-store.category.social'),
developer: () => t('app-store.category.developer'),
crypto: () => t('app-store.category.crypto'),
}
// Small 3D category icons bundled with the OS for offline use
// (converted from the apps.umbrel.com redesign asset set)
export function categoryIcon(navId: string): string | undefined {
if (!(navId in categoryLabels)) return undefined
return `/assets/app-store/categories/${navId}.webp`
}
export const APP_STORE_EMPTY_STATE_SRC = '/assets/app-store/no-results.webp'
// ---------------------------------------------------------------------------
// Remote editorial API (apps.umbrel.com)
// ---------------------------------------------------------------------------
// The storefront feed and release history are optional editorial decoration:
// every id is resolved against the local registry and any failure renders the
// complete local experience instead. See data/storefront.ts.
export const APP_STORE_REMOTE_API_BASE = 'https://apps.umbrel.com/api/v3/umbrelos/app-store'
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
export const DISCOVER_PATH = '/app-store'
export function categoryPath(categoryId: string) {
return `/app-store/category/${categoryId}`
}
// ---------------------------------------------------------------------------
// Shared visual recipes
// ---------------------------------------------------------------------------
// The one section surface used across the store: the refreshed Settings-era
// edge material instead of the old ad hoc gradients.
export const storeCardClass = tw`settings-edge-material rounded-24 bg-white/4`
export const storeCardPaddedClass = tw`settings-edge-material rounded-24 bg-white/4 p-4 md:p-6`
export const appGridClass = tw`grid sm:grid-cols-2 xl:grid-cols-3 gap-x-2.5 gap-y-1`
// Bleeds a horizontally scrolling rail through the sheet's responsive padding
// (layouts/sheet.tsx) so cards scroll edge-to-edge — keep the two in sync.
export const sheetBleedClass = tw`-mx-3 px-3 md:-mx-[40px] md:px-[40px] xl:-mx-[70px] xl:px-[70px]`
// ---------------------------------------------------------------------------
// The store's shared content reveal (see index.css): sections rise gently out
// of a blur as a page appears, orchestrated with small delays so the page
// composes itself top to bottom instead of popping in at once.
export const storeRevealClass = tw`umbrel-store-reveal`
// Blur-and-fade only — for elements that also carry motion-driven transforms
// (the collapsing hero) or chrome that shouldn't visibly move
export const storeRevealSoftClass = tw`umbrel-store-reveal-soft`
// Shorter and subtler — cheap enough for every card of a large grid
export const storeRevealCardClass = tw`umbrel-store-reveal-card`
export const storeRevealDelay = (ms: number) => ({['--store-reveal-delay' as string]: `${ms}ms`})
@@ -0,0 +1,216 @@
// @vitest-environment jsdom
import {describe, expect, test, vi} from 'vitest'
import type {RegistryApp} from '@/trpc/trpc'
import {
buildAppDates,
buildAppStatusMap,
createNameCollator,
deriveAppStatus,
getAppStoreAction,
getAvailableSorts,
getCategoryLabel,
getNavCategories,
getRelatedApps,
sortApps,
} from './catalog'
vi.mock('@/utils/i18n', () => ({t: (key: string) => key}))
const app = (overrides: Partial<RegistryApp>): RegistryApp => ({id: 'app', name: 'App', ...overrides}) as RegistryApp
describe('deriveAppStatus', () => {
test('not installed', () => {
expect(deriveAppStatus({compatible: true, updateAvailable: false})).toBe('available')
})
test('not installed and incompatible', () => {
expect(deriveAppStatus({compatible: false, updateAvailable: false})).toBe('incompatible')
})
test('installed', () => {
expect(deriveAppStatus({compatible: true, installedState: 'running', updateAvailable: false})).toBe('installed')
})
test('installed with update available', () => {
expect(deriveAppStatus({compatible: true, installedState: 'ready', updateAvailable: true})).toBe('update-available')
})
test('in progress wins over update available', () => {
expect(deriveAppStatus({compatible: true, installedState: 'updating', updateAvailable: true})).toBe('in-progress')
expect(deriveAppStatus({compatible: true, installedState: 'installing', updateAvailable: false})).toBe(
'in-progress',
)
})
test('installed but incompatible manifest still reads installed', () => {
expect(deriveAppStatus({compatible: false, installedState: 'running', updateAvailable: false})).toBe('installed')
})
})
describe('buildAppStatusMap', () => {
test('derives status only from the supplied registry entry when IDs overlap', () => {
const officialApp = app({id: 'shared-id', appStoreId: 'umbrel-app-store', compatible: true})
const unrelatedCommunityApp = app({id: 'shared-id', appStoreId: 'community-store', compatible: false})
expect(buildAppStatusMap([officialApp], undefined).get('shared-id')).toBe('available')
expect(buildAppStatusMap([unrelatedCommunityApp], undefined).get('shared-id')).toBe('incompatible')
})
})
describe('getAppStoreAction', () => {
test.each([
['available', 'install'],
['incompatible', 'install'],
['installed', 'open'],
['update-available', 'update'],
['in-progress', undefined],
] as const)('maps %s exhaustively to %s', (status, action) => {
expect(getAppStoreAction(status, false)).toBe(action)
})
test('never offers an action while the live app state is transitioning', () => {
expect(getAppStoreAction('available', true)).toBeUndefined()
expect(getAppStoreAction('installed', true)).toBeUndefined()
expect(getAppStoreAction('update-available', true)).toBeUndefined()
})
})
describe('buildAppDates', () => {
const local = {
immich: {version: '2.0.0'},
plex: {version: '1.5.0'},
}
test('applies createdAt unconditionally and updatedAt only on version match', () => {
const dates = buildAppDates(
[
{id: 'immich', version: '2.0.0', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z'},
// Remote is ahead of this device: updatedAt must not be applied
{id: 'plex', version: '9.9.9', createdAt: '2023-01-01T00:00:00Z', updatedAt: '2026-06-01T00:00:00Z'},
],
local,
)
expect(dates.get('immich')).toEqual({
createdAt: Date.parse('2024-01-01T00:00:00Z'),
updatedAt: Date.parse('2026-05-01T00:00:00Z'),
})
expect(dates.get('plex')).toEqual({createdAt: Date.parse('2023-01-01T00:00:00Z')})
})
test('drops unknown apps and invalid dates', () => {
const dates = buildAppDates(
[
{id: 'not-local', version: '1.0.0', createdAt: '2024-01-01T00:00:00Z', updatedAt: null},
{id: 'immich', version: '2.0.0', createdAt: 'not-a-date', updatedAt: undefined},
],
local,
)
expect(dates.size).toBe(0)
})
})
describe('getAvailableSorts', () => {
test('only name without remote dates', () => {
expect(getAvailableSorts(undefined)).toEqual(['name'])
expect(getAvailableSorts(new Map())).toEqual(['name'])
})
test('date sorts appear only when the metadata supports them', () => {
expect(getAvailableSorts(new Map([['a', {createdAt: 1}]]))).toEqual(['name', 'newest'])
expect(getAvailableSorts(new Map([['a', {updatedAt: 1}]]))).toEqual(['name', 'recently-updated'])
expect(getAvailableSorts(new Map([['a', {createdAt: 1, updatedAt: 2}]]))).toEqual([
'name',
'newest',
'recently-updated',
])
})
})
describe('sortApps', () => {
const apps = [
app({id: 'zebra', name: 'zebra'}),
app({id: 'alpha', name: 'Alpha'}),
app({id: 'numeric', name: 'App 10'}),
app({id: 'numeric2', name: 'App 2'}),
]
test('name sort is case-insensitive and numeric-aware', () => {
const sorted = sortApps(apps, 'name', undefined, createNameCollator('en'))
expect(sorted.map(({name}) => name)).toEqual(['Alpha', 'App 2', 'App 10', 'zebra'])
})
test('is stable and does not mutate its input', () => {
const input = [...apps]
sortApps(input, 'name', undefined, createNameCollator('en'))
expect(input).toEqual(apps)
})
test('newest puts undated apps last, alphabetically', () => {
const dates = new Map([
['alpha', {createdAt: 100}],
['zebra', {createdAt: 200}],
])
const sorted = sortApps(apps, 'newest', dates, createNameCollator('en'))
expect(sorted.map(({id}) => id)).toEqual(['zebra', 'alpha', 'numeric2', 'numeric'])
})
test('recently-updated orders by updatedAt descending', () => {
const dates = new Map([
['alpha', {updatedAt: 300}],
['numeric', {updatedAt: 500}],
])
const sorted = sortApps(apps, 'recently-updated', dates, createNameCollator('en'))
expect(sorted.map(({id}) => id)).toEqual(['numeric', 'alpha', 'numeric2', 'zebra'])
})
})
describe('getCategoryLabel', () => {
test('predefined categories use translations', () => {
expect(getCategoryLabel('ai')).toBe('app-store.category.ai')
})
test('unknown manifest categories are title-cased', () => {
expect(getCategoryLabel('home_lab-tools')).toBe('Home Lab Tools')
})
})
describe('getNavCategories', () => {
test('keeps canonical order, drops empty predefined categories, appends dynamic ones', () => {
const nav = getNavCategories({
ai: [{}],
bitcoin: [],
gaming: [{}],
})
expect(nav[0]).toBe('discover')
expect(nav[1]).toBe('all')
expect(nav).toContain('ai')
expect(nav).not.toContain('bitcoin')
expect(nav.at(-1)).toBe('gaming')
})
})
describe('getRelatedApps', () => {
const apps = [
app({id: 'a', category: 'media'}),
app({id: 'b', category: 'media'}),
app({id: 'c', category: 'media'}),
app({id: 'd', category: 'files'}),
]
test('returns same-category apps, excluding the app itself, deterministically', () => {
const related = getRelatedApps(apps, 'a', 2)
expect(related).toHaveLength(2)
expect(related.map(({id}) => id)).not.toContain('a')
expect(related.map(({id}) => id)).not.toContain('d')
expect(getRelatedApps(apps, 'a', 2)).toEqual(related)
})
test('unknown app returns nothing', () => {
expect(getRelatedApps(apps, 'missing')).toEqual([])
})
})
@@ -0,0 +1,228 @@
// Pure catalog selectors for the App Store.
//
// "Local truth, remote taste": the locally synced registry decides which apps
// exist and what state they're in. Remote metadata (creation/update dates from
// apps.umbrel.com) may only decorate or order apps that already exist locally,
// which is why every selector here takes local registry data as its base.
import {arrayIncludes} from 'ts-extras'
import {categories, categoryLabels, categoryNavOrder} from '@/features/app-store/constants'
import type {AppState, RegistryApp, UserApp} from '@/trpc/trpc'
// ---------------------------------------------------------------------------
// App status
// ---------------------------------------------------------------------------
export type AppStoreStatus = 'available' | 'installed' | 'update-available' | 'in-progress' | 'incompatible'
export type AppStoreAction = 'install' | 'open' | 'update'
// States that represent an action in flight (mirrors pollStates in
// use-app-install.ts, duplicated here to keep this module dependency-free)
const inProgressStates = ['installing', 'uninstalling', 'updating', 'starting', 'restarting', 'stopping'] as const
export function deriveAppStatus({
compatible,
installedState,
updateAvailable,
}: {
compatible: boolean
/** State of the installed app, or undefined when not installed */
installedState?: AppState
updateAvailable: boolean
}): AppStoreStatus {
if (installedState === undefined) return compatible ? 'available' : 'incompatible'
if (arrayIncludes(inProgressStates, installedState)) return 'in-progress'
if (updateAvailable) return 'update-available'
return 'installed'
}
export function buildAppStatusMap(
apps: readonly RegistryApp[],
userAppsKeyed: Record<string, UserApp> | undefined,
): Map<string, AppStoreStatus> {
const statuses = new Map<string, AppStoreStatus>()
for (const app of apps) {
const userApp = userAppsKeyed?.[app.id]
statuses.set(
app.id,
deriveAppStatus({
compatible: app.compatible,
installedState: userApp?.state,
updateAvailable: Boolean(userApp && app.version !== userApp.version),
}),
)
}
return statuses
}
/** The only valid card action for a settled catalog/live-state combination. */
export function getAppStoreAction(status: AppStoreStatus, transitioning: boolean): AppStoreAction | undefined {
if (transitioning || status === 'in-progress') return undefined
switch (status) {
case 'available':
case 'incompatible':
return 'install'
case 'installed':
return 'open'
case 'update-available':
return 'update'
}
}
// ---------------------------------------------------------------------------
// Remote date metadata
// ---------------------------------------------------------------------------
export type AppDates = {
/** Epoch ms. Applied unconditionally — creation date is not version-sensitive. */
createdAt?: number
/** Epoch ms. Applied only when the remote version matches the local version. */
updatedAt?: number
}
/**
* Builds a per-app date map from remote metadata, reconciled against the
* local registry: `createdAt` is always applied, `updatedAt` only when the
* remote entry describes the exact version the local registry offers, so a
* remote feed that is ahead of the device can never claim a newer update date
* than what is actually installable.
*/
export function buildAppDates(
remoteApps: readonly {id: string; version: string; createdAt?: string | null; updatedAt?: string | null}[],
localAppsKeyed: Record<string, Pick<RegistryApp, 'version'>>,
): Map<string, AppDates> {
const dates = new Map<string, AppDates>()
for (const remoteApp of remoteApps) {
const localApp = localAppsKeyed[remoteApp.id]
if (!localApp) continue
const entry: AppDates = {}
const createdAt = parseDate(remoteApp.createdAt)
if (createdAt !== undefined) entry.createdAt = createdAt
if (remoteApp.version === localApp.version) {
const updatedAt = parseDate(remoteApp.updatedAt)
if (updatedAt !== undefined) entry.updatedAt = updatedAt
}
if (entry.createdAt !== undefined || entry.updatedAt !== undefined) dates.set(remoteApp.id, entry)
}
return dates
}
function parseDate(value: string | null | undefined): number | undefined {
if (!value) return undefined
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? undefined : parsed
}
// ---------------------------------------------------------------------------
// Sorting
// ---------------------------------------------------------------------------
export type AppSortId = 'name' | 'newest' | 'recently-updated'
/**
* Date-based sorts are only offered when remote metadata actually provides
* usable dates; offline the store simply shows the always-available options
* instead of disabled controls.
*/
export function getAvailableSorts(dates: Map<string, AppDates> | undefined): AppSortId[] {
const sorts: AppSortId[] = ['name']
if (!dates || dates.size === 0) return sorts
let hasCreatedAt = false
let hasUpdatedAt = false
for (const entry of dates.values()) {
hasCreatedAt ||= entry.createdAt !== undefined
hasUpdatedAt ||= entry.updatedAt !== undefined
if (hasCreatedAt && hasUpdatedAt) break
}
if (hasCreatedAt) sorts.push('newest')
if (hasUpdatedAt) sorts.push('recently-updated')
return sorts
}
export function createNameCollator(locale?: string) {
// Stable, human-friendly ordering: case-insensitive, numeric-aware
return new Intl.Collator(locale, {sensitivity: 'base', numeric: true})
}
export function sortApps(
apps: readonly RegistryApp[],
sort: AppSortId,
dates: Map<string, AppDates> | undefined,
collator: Intl.Collator = createNameCollator(),
): RegistryApp[] {
const byName = (a: RegistryApp, b: RegistryApp) => collator.compare(a.name, b.name) || a.id.localeCompare(b.id)
if (sort === 'name' || !dates) return [...apps].sort(byName)
const key = sort === 'newest' ? 'createdAt' : 'updatedAt'
return [...apps].sort((a, b) => {
const aDate = dates.get(a.id)?.[key]
const bDate = dates.get(b.id)?.[key]
// Apps without dates sort after dated apps, alphabetically
if (aDate === undefined && bDate === undefined) return byName(a, b)
if (aDate === undefined) return 1
if (bDate === undefined) return -1
return bDate - aDate || byName(a, b)
})
}
// ---------------------------------------------------------------------------
// Related apps
// ---------------------------------------------------------------------------
// Small deterministic string hash so "you might also like" is stable per app
// across visits instead of reshuffling on every mount
function stableRank(seed: string, appId: string): number {
const value = `${seed}:${appId}`
let hash = 0
for (let index = 0; index < value.length; index++) {
hash = (hash * 31 + value.charCodeAt(index)) | 0
}
return hash >>> 0
}
export function getRelatedApps(apps: readonly RegistryApp[], appId: string, count = 5): RegistryApp[] {
const app = apps.find((candidate) => candidate.id === appId)
if (!app) return []
return apps
.filter((candidate) => candidate.category === app.category && candidate.id !== appId)
.sort((a, b) => stableRank(appId, a.id) - stableRank(appId, b.id) || a.id.localeCompare(b.id))
.slice(0, count)
}
// ---------------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------------
export function getCategoryLabel(categoryId: string): string {
const label = categoryLabels[categoryId as keyof typeof categoryLabels]
if (label) return label()
// Unknown manifest categories are title-cased for display
return categoryId
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
/**
* The navigation rail: Discover and All apps, then the predefined categories
* (in their canonical order) that actually have apps, then any dynamic
* categories coming from app manifests umbrelOS doesn't know about yet.
*/
export function getNavCategories(appsGroupedByCategory: Record<string, readonly unknown[]>): string[] {
const hasApps = (categoryId: string) => (appsGroupedByCategory[categoryId]?.length ?? 0) > 0
const dynamicCategories = Object.keys(appsGroupedByCategory)
.filter((categoryId) => !arrayIncludes(categories, categoryId))
.sort()
return ['discover', 'all', ...categoryNavOrder.filter(hasApps), ...dynamicCategories.filter(hasApps)]
}
@@ -0,0 +1,14 @@
import type {RegistryApp} from '@/trpc/trpc'
import {preloadImage} from '@/utils/misc'
const alreadyPreloaded = new Set<string>()
/**
* Warms the first few gallery screenshots on hover intent so the app page
* feels instant, without eagerly downloading galleries for every card.
*/
export function preloadFirstFewGalleryImages(app: RegistryApp) {
if (alreadyPreloaded.has(app.id)) return
alreadyPreloaded.add(app.id)
app.gallery.slice(0, 3).map(preloadImage)
}
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import {describe, expect, test, vi} from 'vitest'
import {hasTimelineContent, parseAppReleases, reconcileReleases, type AppReleases} from './releases'
vi.mock('@/utils/i18n', () => ({t: (key: string) => key}))
const localApp = {id: 'immich', version: '2.0.0', releaseNotes: 'Local notes for 2.0.0'}
const remote = (overrides: Partial<AppReleases> = {}): AppReleases => ({
schemaVersion: 1,
id: 'immich',
version: '2.0.0',
releases: [
{version: '2.0.0', date: '2026-05-01T00:00:00Z', notes: 'Remote notes for 2.0.0'},
{version: '1.9.0', date: '2026-03-01T00:00:00Z', notes: 'Remote notes for 1.9.0'},
{version: '1.8.0', date: '2026-01-01T00:00:00Z', notes: ''},
],
...overrides,
})
describe('parseAppReleases', () => {
test('accepts valid data and rejects malformed data', () => {
expect(parseAppReleases(remote())).toBeTruthy()
expect(() => parseAppReleases({schemaVersion: 1, id: 'x'})).toThrow()
expect(() => parseAppReleases(null)).toThrow()
})
})
describe('hasTimelineContent', () => {
test('a single undated entry without notes is not worth showing', () => {
expect(hasTimelineContent([])).toBe(false)
expect(hasTimelineContent([{version: '1.0.0', notes: ' '}])).toBe(false)
})
test('notes, dates, or history make the timeline worth showing', () => {
expect(hasTimelineContent([{version: '1.0.0', notes: 'Notes'}])).toBe(true)
expect(hasTimelineContent([{version: '1.0.0', date: 1, notes: ''}])).toBe(true)
expect(
hasTimelineContent([
{version: '1.1.0', notes: ''},
{version: '1.0.0', notes: ''},
]),
).toBe(true)
})
})
describe('reconcileReleases', () => {
test('remote current version matches local: shows dated timeline', () => {
const timeline = reconcileReleases(localApp, remote())
expect(timeline).toHaveLength(3)
expect(timeline[0]).toEqual({
version: '2.0.0',
date: Date.parse('2026-05-01T00:00:00Z'),
notes: 'Remote notes for 2.0.0',
})
})
test('remote ahead of local: only entries from the local version down are shown', () => {
const ahead = remote({
version: '2.0.0',
releases: [
{version: '2.1.0', date: '2026-06-01T00:00:00Z', notes: 'Not installable here'},
{version: '2.0.0', date: '2026-05-01T00:00:00Z', notes: 'Current'},
{version: '1.9.0', date: '2026-03-01T00:00:00Z', notes: 'Old'},
],
})
const timeline = reconcileReleases(localApp, ahead)
expect(timeline.map(({version}) => version)).toEqual(['2.0.0', '1.9.0'])
})
test('remote current version mismatch falls back to local notes only', () => {
const timeline = reconcileReleases(localApp, remote({version: '3.0.0'}))
expect(timeline).toEqual([{version: '2.0.0', notes: 'Local notes for 2.0.0'}])
})
test('remote data for a different app is discarded', () => {
const timeline = reconcileReleases(localApp, remote({id: 'plex'}))
expect(timeline).toEqual([{version: '2.0.0', notes: 'Local notes for 2.0.0'}])
})
test('missing remote data falls back to local notes', () => {
expect(reconcileReleases(localApp, undefined)).toEqual([{version: '2.0.0', notes: 'Local notes for 2.0.0'}])
})
test('empty remote notes for the current version fall back to local notes', () => {
const timeline = reconcileReleases(
localApp,
remote({
releases: [{version: '2.0.0', date: '2026-05-01T00:00:00Z', notes: ' '}],
}),
)
expect(timeline[0]?.notes).toBe('Local notes for 2.0.0')
})
test('local version missing from remote history falls back to local notes', () => {
const timeline = reconcileReleases(
localApp,
remote({
releases: [{version: '1.9.0', date: '2026-03-01T00:00:00Z', notes: 'Old'}],
}),
)
expect(timeline).toEqual([{version: '2.0.0', notes: 'Local notes for 2.0.0'}])
})
})
@@ -0,0 +1,111 @@
// Schema and reconciliation for the optional per-app release history from
// apps.umbrel.com.
//
// The local registry's current version and release notes remain authoritative:
// remote history is only accepted for the same app and only shown when its
// current version matches the local one, so a feed that is ahead of the local
// registry can never present releases as if they were installable. Any failure
// falls back to the local current release notes.
import {queryOptions} from '@tanstack/react-query'
import {z} from 'zod'
import {APP_STORE_REMOTE_API_BASE} from '@/features/app-store/constants'
import {remoteJsonFetcher} from '@/features/app-store/data/storefront-query'
import type {RegistryApp} from '@/trpc/trpc'
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const appReleasesSchema = z.object({
schemaVersion: z.literal(1),
id: z.string().min(1).max(64),
version: z.string().min(1).max(64),
releases: z
.array(
z.object({
version: z.string().min(1).max(64),
date: z.string().max(64).nullish(),
notes: z.string().max(10000),
}),
)
.max(5),
})
export type AppReleases = z.infer<typeof appReleasesSchema>
export function parseAppReleases(data: unknown): AppReleases {
return appReleasesSchema.parse(data)
}
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------
export function appReleasesQueryOptions(appId: string) {
return queryOptions<AppReleases>({
queryKey: ['app-store', 'app-releases', appId],
queryFn: async ({signal}) => {
const json = (await remoteJsonFetcher(`${APP_STORE_REMOTE_API_BASE}/apps/${encodeURIComponent(appId)}`)({
signal,
})) as {data?: unknown}
return parseAppReleases(json?.data)
},
retry: false,
staleTime: 60 * 60 * 1000,
})
}
// ---------------------------------------------------------------------------
// Reconciliation
// ---------------------------------------------------------------------------
export type ReleaseTimelineEntry = {
version: string
/** Epoch ms; undefined when only local data is available */
date?: number
notes: string
}
/**
* Merges remote release history with the authoritative local release info.
*
* - Remote data for a different app id or a mismatched current version is
* discarded entirely (the feed may be ahead of or behind this device).
* - When accepted, entries newer than the local current version are dropped
* and the newest entry's notes fall back to the local release notes if the
* remote ones are empty.
* - With no usable remote data the timeline is the local current release only.
*/
/** A single undated entry without notes isn't worth a "What's new" card */
export function hasTimelineContent(entries: readonly ReleaseTimelineEntry[]): boolean {
const [latest] = entries
if (!latest) return false
return entries.length > 1 || latest.date !== undefined || Boolean(latest.notes.trim())
}
export function reconcileReleases(
app: Pick<RegistryApp, 'id' | 'version' | 'releaseNotes'>,
remote: AppReleases | undefined,
): ReleaseTimelineEntry[] {
const localOnly: ReleaseTimelineEntry[] = [{version: app.version, notes: app.releaseNotes ?? ''}]
if (!remote || remote.id !== app.id || remote.version !== app.version) return localOnly
// Drop any entries the feed claims are newer than what this device can
// install; the first remaining entry should describe the local version.
const startIndex = remote.releases.findIndex((release) => release.version === app.version)
if (startIndex === -1) return localOnly
const entries = remote.releases.slice(startIndex).map((release, index) => {
const date = release.date ? Date.parse(release.date) : Number.NaN
return {
version: release.version,
date: Number.isNaN(date) ? undefined : date,
notes: index === 0 && !release.notes.trim() ? (app.releaseNotes ?? '') : release.notes,
}
})
return entries.length > 0 ? entries : localOnly
}
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import {describe, expect, test, vi} from 'vitest'
import type {RegistryApp} from '@/trpc/trpc'
import {createAppStoreSearch} from './search'
vi.mock('@/utils/i18n', () => ({t: (key: string) => key}))
const app = (overrides: Partial<RegistryApp>): RegistryApp =>
({
id: 'app',
name: 'App',
tagline: '',
description: '',
developer: '',
category: 'files',
...overrides,
}) as RegistryApp
const apps = [
app({id: 'immich', name: 'Immich', tagline: 'High-performance photo backup', developer: 'Alex Tran'}),
app({id: 'jellyfin', name: 'Jellyfin', tagline: 'The Free Software Media System', category: 'media'}),
app({id: 'nextcloud', name: 'Nextcloud', tagline: 'Productivity platform', description: 'Sync photos and files'}),
]
describe('createAppStoreSearch', () => {
const search = createAppStoreSearch(apps)
test('matches by name first', () => {
expect(search('immich')[0]?.id).toBe('immich')
})
test('name matches rank above description matches', () => {
const results = search('photo')
expect(results.map(({id}) => id)).toContain('immich')
expect(results.map(({id}) => id)).toContain('nextcloud')
// "photo" in Immich's tagline should outrank "photos" in Nextcloud's description
expect(results[0]?.id).toBe('immich')
})
test('matches by developer', () => {
expect(search('alex tran').map(({id}) => id)).toContain('immich')
})
test('matches by category label', () => {
// The mocked t() returns the key, so the label for 'media' is 'app-store.category.media'
expect(search('category.media').map(({id}) => id)).toContain('jellyfin')
})
test('empty and whitespace queries return nothing', () => {
expect(search('')).toEqual([])
expect(search(' ')).toEqual([])
})
})
@@ -0,0 +1,27 @@
// Local, instant app search. Searches only local registry fields — typing
// never triggers a network request.
import Fuse from 'fuse.js'
import {getCategoryLabel} from '@/features/app-store/data/catalog'
import type {RegistryApp} from '@/trpc/trpc'
import {fuseOptions} from '@/utils/search'
const searchKeys = [
{name: 'name', weight: 3},
{name: 'tagline', weight: 2},
{name: 'description', weight: 1},
{name: 'developer', weight: 1},
// Lets "productivity" or a translated category name surface its apps
{name: 'categoryLabel', weight: 1, getFn: (app: RegistryApp) => getCategoryLabel(app.category)},
]
export function createAppStoreSearch(apps: readonly RegistryApp[]) {
const fuse = new Fuse<RegistryApp>(apps as RegistryApp[], {...fuseOptions, keys: searchKeys})
return (pattern: string, limit = 60): RegistryApp[] => {
const normalizedPattern = pattern.trim().replace(/\s+/g, ' ')
if (!normalizedPattern) return []
return fuse.search(normalizedPattern, {limit}).map((result) => result.item)
}
}
@@ -0,0 +1,56 @@
// Shared query definition for the optional storefront feed, used by the
// Discover/category pages and by the idle Prefetcher so the fetch itself is
// defined exactly once.
//
// Failure policy: a slow, blocked, malformed or offline apps.umbrel.com must
// never degrade the local store, so the request has a short timeout, never
// retries, sends no credentials/referrer, and errors are simply treated as
// "no editorial content" by the UI (no toasts, no error cards).
import {queryOptions} from '@tanstack/react-query'
import {APP_STORE_REMOTE_API_BASE} from '@/features/app-store/constants'
import {parseStorefront, type Storefront} from '@/features/app-store/data/storefront'
export const REMOTE_TIMEOUT_MS = 3000
// The feed is editorial, not operational — refreshing it mid-session has no
// user value, so keep it fresh for the session and let HTTP caching handle
// the rest across sessions. The cache lifetime matches: with react-query's
// default 5-minute gcTime the prefetched feed (and the feed from a previous
// visit — closing the store unmounts its only observer) would be evicted, so
// reopening the store would compose Discover twice: first the local catalog
// alone, then the editorial sections landing on top of it.
const STALE_TIME_MS = 60 * 60 * 1000
const GC_TIME_MS = STALE_TIME_MS
export function remoteJsonFetcher(url: string) {
return async ({signal}: {signal?: AbortSignal} = {}): Promise<unknown> => {
const timeoutSignal = AbortSignal.timeout(REMOTE_TIMEOUT_MS)
const response = await fetch(url, {
credentials: 'omit',
referrerPolicy: 'no-referrer',
signal: signal && 'any' in AbortSignal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
})
if (!response.ok) throw new Error(`Unexpected status ${response.status}`)
if (!response.headers.get('content-type')?.includes('application/json')) {
throw new Error('Unexpected content type')
}
return response.json()
}
}
const fetchStorefrontJson = remoteJsonFetcher(`${APP_STORE_REMOTE_API_BASE}/storefront`)
export function storefrontQueryOptions() {
return queryOptions<Storefront>({
queryKey: ['app-store', 'storefront'],
queryFn: async ({signal}) => {
const json = (await fetchStorefrontJson({signal})) as {data?: unknown}
return parseStorefront(json?.data)
},
retry: false,
staleTime: STALE_TIME_MS,
gcTime: GC_TIME_MS,
})
}
@@ -0,0 +1,235 @@
// @vitest-environment jsdom
import {describe, expect, test, vi} from 'vitest'
import {APP_STORE_REMOTE_API_BASE} from '@/features/app-store/constants'
import type {RegistryApp} from '@/trpc/trpc'
import {parseStorefront, resolveStorefront, STOREFRONT_LIMITS, type Storefront} from './storefront'
vi.mock('@/utils/i18n', () => ({t: (key: string) => key}))
// Artwork must be hosted alongside the API, so build fixture URLs from the
// configured base — the tests then hold for both the production and the
// temporary local-development API base.
const artworkUrl = `${new URL(APP_STORE_REMOTE_API_BASE).origin}/images/redesign/banners/banner-openclaw.webp`
const artwork = {dark: artworkUrl}
const validFeed = {
schemaVersion: 1,
sections: [
{id: 'rail', type: 'app-list', layout: 'rail', title: 'Serving hot now', subtitle: 'Featured', appIds: ['a', 'b']},
{id: 'spot', type: 'spotlight', banners: [{appId: 'a', artwork}]},
{
id: 'cat',
type: 'category-feature',
categoryId: 'ai',
title: 'AI at home',
description: 'Desc',
appIds: ['a', 'b', 'c'],
artwork,
},
],
categories: [{id: 'ai', featuredAppIds: ['a', 'missing']}],
apps: [
{id: 'a', version: '1.0.0', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z'},
{id: 'b', version: '9.9.9', createdAt: '2025-01-01T00:00:00Z', updatedAt: '2026-02-01T00:00:00Z'},
],
}
describe('parseStorefront', () => {
test('accepts a valid feed', () => {
const storefront = parseStorefront(validFeed)
expect(storefront.sections).toHaveLength(3)
expect(storefront.categories).toHaveLength(1)
expect(storefront.apps).toHaveLength(2)
})
test('silently drops unknown future section types and malformed sections', () => {
const storefront = parseStorefront({
...validFeed,
sections: [
...validFeed.sections,
{id: 'future', type: 'video-hero', title: 'New hotness', videoUrl: 'https://apps.umbrel.com/x.mp4'},
{id: 'broken', type: 'app-list', layout: 'grid', title: '', appIds: []},
],
})
expect(storefront.sections.map(({id}) => id)).toEqual(['rail', 'spot', 'cat'])
})
test('rejects malformed envelopes', () => {
expect(() => parseStorefront(undefined)).toThrow()
expect(() => parseStorefront({schemaVersion: 2, sections: []})).toThrow()
expect(() => parseStorefront('<!doctype html>')).toThrow()
})
test('rejects artwork not hosted alongside the API', () => {
const storefront = parseStorefront({
...validFeed,
sections: [
{
id: 'spot',
type: 'spotlight',
banners: [{appId: 'a', artwork: {dark: 'https://evil.example/x.png'}}],
},
],
})
expect(storefront.sections).toHaveLength(0)
})
test.each([
['sections', {...validFeed, sections: Array.from({length: STOREFRONT_LIMITS.sections + 1}, () => null)}],
[
'categories',
{
...validFeed,
categories: Array.from({length: STOREFRONT_LIMITS.categories + 1}, (_, index) => ({
id: `category-${index}`,
featuredAppIds: [],
})),
},
],
[
'app metadata',
{
...validFeed,
apps: Array.from({length: STOREFRONT_LIMITS.appMetadata + 1}, (_, index) => ({
id: `app-${index}`,
version: '1.0.0',
})),
},
],
] as const)('rejects oversized top-level %s collections', (_name, feed) => {
expect(() => parseStorefront(feed)).toThrow()
})
test('drops sections with oversized app or banner collections', () => {
const storefront = parseStorefront({
...validFeed,
sections: [
{
id: 'apps',
type: 'app-list',
layout: 'grid',
title: 'Apps',
appIds: Array.from({length: STOREFRONT_LIMITS.sectionAppIds + 1}, () => 'a'),
},
{
id: 'spotlight',
type: 'spotlight',
banners: Array.from({length: STOREFRONT_LIMITS.spotlightBanners + 1}, () => ({appId: 'a', artwork})),
},
],
})
expect(storefront.sections).toEqual([])
})
test('rejects oversized category featured-app collections', () => {
expect(() =>
parseStorefront({
...validFeed,
categories: [
{
id: 'ai',
featuredAppIds: Array.from({length: STOREFRONT_LIMITS.categoryFeaturedAppIds + 1}, () => 'a'),
},
],
}),
).toThrow()
})
})
describe('resolveStorefront', () => {
const localApps: Record<string, RegistryApp> = {
a: {id: 'a', name: 'A', version: '1.0.0'} as RegistryApp,
b: {id: 'b', name: 'B', version: '2.0.0'} as RegistryApp,
}
const localCategories = {ai: [{}]}
const storefront = (overrides: Partial<Storefront> = {}): Storefront => ({
...parseStorefront(validFeed),
...overrides,
})
test('resolves ids against the local registry and drops missing apps', () => {
const resolved = resolveStorefront(storefront(), localApps, localCategories)
const categoryFeature = resolved.sections.find(({type}) => type === 'category-feature')
expect(categoryFeature && 'apps' in categoryFeature && categoryFeature.apps.map(({id}) => id)).toEqual(['a', 'b'])
})
test('deduplicates repeated ids', () => {
const resolved = resolveStorefront(
storefront({
sections: [{id: 'rail', type: 'app-list', layout: 'rail', title: 'Rail', appIds: ['a', 'a', 'b']}],
}),
localApps,
localCategories,
)
const rail = resolved.sections[0]
expect(rail && 'apps' in rail && rail.apps.map(({id}) => id)).toEqual(['a', 'b'])
})
test('drops sections without enough locally available content', () => {
const resolved = resolveStorefront(
storefront({
sections: [
{id: 'rail', type: 'app-list', layout: 'rail', title: 'Rail', appIds: ['missing']},
{id: 'spot', type: 'spotlight', banners: [{appId: 'missing', artwork}]},
{
id: 'cat-no-local-category',
type: 'category-feature',
categoryId: 'gaming',
title: 'T',
description: 'D',
appIds: ['a'],
artwork,
textSide: 'left',
},
],
}),
localApps,
localCategories,
)
expect(resolved.sections).toHaveLength(0)
})
test('spotlight keeps one banner per locally available app', () => {
const resolved = resolveStorefront(
storefront({
sections: [
{
id: 'spot',
type: 'spotlight',
banners: [
{appId: 'missing', artwork},
{appId: 'b', artwork},
{appId: 'a', artwork},
{appId: 'b', artwork},
],
},
],
}),
localApps,
localCategories,
)
const spotlight = resolved.sections[0]
expect(spotlight && 'banners' in spotlight && spotlight.banners.map(({app}) => app.id)).toEqual(['b', 'a'])
})
test('featured categories drop unknown apps; dates gate updatedAt on version match', () => {
const resolved = resolveStorefront(storefront(), localApps, localCategories)
expect(resolved.featuredByCategory.get('ai')?.map(({id}) => id)).toEqual(['a'])
// App a: versions match — both dates. App b: remote 9.9.9 vs local 2.0.0 — createdAt only.
expect(resolved.dates.get('a')).toEqual({
createdAt: Date.parse('2024-01-01T00:00:00Z'),
updatedAt: Date.parse('2026-01-01T00:00:00Z'),
})
expect(resolved.dates.get('b')).toEqual({createdAt: Date.parse('2025-01-01T00:00:00Z')})
})
})
@@ -0,0 +1,238 @@
// Schema and resolution for the optional apps.umbrel.com storefront feed.
//
// The feed can only ever decorate the store: every app id is resolved against
// the local registry and silently dropped when unknown, unknown section types
// are ignored for forwards compatibility, and any malformed payload is treated
// exactly like having no remote data at all. No remote response can create an
// installable app, override a local version, or block a local route.
import {z} from 'zod'
import {APP_STORE_REMOTE_API_BASE} from '@/features/app-store/constants'
import {buildAppDates, type AppDates} from '@/features/app-store/data/catalog'
import type {RegistryApp} from '@/trpc/trpc'
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const boundedString = (max: number) => z.string().trim().min(1).max(max)
// Comfortably above the production catalog/editorial layout while keeping a
// malformed feed from multiplying work during validation and reconciliation.
export const STOREFRONT_LIMITS = {
sections: 32,
sectionAppIds: 512,
spotlightBanners: 24,
categories: 64,
categoryFeaturedAppIds: 24,
appMetadata: 1024,
} as const
const appIdSchema = z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9-]+$/)
const appIdsSchema = z.array(appIdSchema).max(STOREFRONT_LIMITS.sectionAppIds)
// Artwork must come from the same origin as the API itself (apps.umbrel.com in
// production) so a bad feed can't turn devices into requesters of arbitrary
// third-party URLs.
const artworkUrlSchema = z
.string()
.max(1024)
.refine((value) => {
try {
const url = new URL(value)
const apiOrigin = new URL(APP_STORE_REMOTE_API_BASE).origin
return url.origin === apiOrigin && (url.protocol === 'https:' || apiOrigin.startsWith('http://localhost'))
} catch {
return false
}
}, 'artwork must be hosted alongside the storefront API')
// umbrelOS is dark-only, so artwork is a single image
const artworkSchema = z.object({
dark: artworkUrlSchema,
})
const sectionBaseSchema = z.object({
id: boundedString(64),
title: boundedString(80),
})
const appListSectionSchema = sectionBaseSchema.extend({
type: z.literal('app-list'),
layout: z.enum(['grid', 'rail']),
subtitle: boundedString(40).optional(),
appIds: appIdsSchema,
})
// One section of banners. Each banner is a complete editorial composition
// (the app's icon, name and headline are part of the image), so all a banner
// needs is the app it opens and its artwork.
const spotlightSectionSchema = z.object({
id: boundedString(64),
type: z.literal('spotlight'),
banners: z.array(z.object({appId: appIdSchema, artwork: artworkSchema})).max(STOREFRONT_LIMITS.spotlightBanners),
})
const categoryFeatureSectionSchema = sectionBaseSchema.extend({
type: z.literal('category-feature'),
categoryId: boundedString(64),
description: boundedString(300),
appIds: appIdsSchema,
artwork: artworkSchema,
// Which side of the artwork is clear enough to place copy on
textSide: z.enum(['left', 'right']).default('left'),
})
const knownSectionSchema = z.discriminatedUnion('type', [
appListSectionSchema,
spotlightSectionSchema,
categoryFeatureSectionSchema,
])
export type StorefrontSection = z.infer<typeof knownSectionSchema>
const storefrontSchema = z.object({
schemaVersion: z.literal(1),
// Sections are validated individually below so a single malformed or
// unknown-typed section drops that section, not the whole feed
sections: z.array(z.unknown()).max(STOREFRONT_LIMITS.sections),
categories: z
.array(
z.object({
id: boundedString(64),
featuredAppIds: z.array(appIdSchema).max(STOREFRONT_LIMITS.categoryFeaturedAppIds),
}),
)
.max(STOREFRONT_LIMITS.categories)
.default([]),
apps: z
.array(
z.object({
id: appIdSchema,
version: boundedString(64),
createdAt: z.string().max(64).nullish(),
updatedAt: z.string().max(64).nullish(),
}),
)
.max(STOREFRONT_LIMITS.appMetadata)
.default([]),
})
export type Storefront = {
sections: StorefrontSection[]
categories: {id: string; featuredAppIds: string[]}[]
apps: z.infer<typeof storefrontSchema>['apps']
}
/**
* Parses the raw `data` payload of the storefront endpoint. Throws when the
* envelope is malformed (the caller treats that as "no remote data");
* individual sections that are malformed or of an unknown future type are
* dropped silently.
*/
export function parseStorefront(data: unknown): Storefront {
const parsed = storefrontSchema.parse(data)
const sections: StorefrontSection[] = []
for (const rawSection of parsed.sections) {
const section = knownSectionSchema.safeParse(rawSection)
if (section.success) sections.push(section.data)
}
return {sections, categories: parsed.categories, apps: parsed.apps}
}
// ---------------------------------------------------------------------------
// Resolution against the local registry
// ---------------------------------------------------------------------------
export type SpotlightBanner = {app: RegistryApp; artwork: {dark: string}}
export type ResolvedSection =
| {type: 'app-list'; id: string; layout: 'grid' | 'rail'; title: string; subtitle?: string; apps: RegistryApp[]}
| {type: 'spotlight'; id: string; banners: SpotlightBanner[]}
| {
type: 'category-feature'
id: string
categoryId: string
title: string
description: string
apps: RegistryApp[]
artwork: {dark: string}
textSide: 'left' | 'right'
}
export type ResolvedStorefront = {
sections: ResolvedSection[]
/** Up to 6 locally available featured apps per category id */
featuredByCategory: Map<string, RegistryApp[]>
/** Reconciled creation/update dates for locally available apps */
dates: Map<string, AppDates>
}
/**
* Resolves a parsed feed against the local registry. Missing apps are dropped,
* ids are deduplicated, and sections without enough locally available content
* disappear entirely rather than rendering half-broken.
*/
export function resolveStorefront(
storefront: Storefront,
localAppsKeyed: Record<string, RegistryApp>,
localCategories: Record<string, readonly unknown[]> = {},
): ResolvedStorefront {
const resolveApps = (appIds: readonly string[]) => {
const resolved: RegistryApp[] = []
const seen = new Set<string>()
for (const appId of appIds) {
if (seen.has(appId)) continue
seen.add(appId)
const app = localAppsKeyed[appId]
if (app) resolved.push(app)
}
return resolved
}
const sections: ResolvedSection[] = []
for (const section of storefront.sections) {
if (section.type === 'app-list') {
const apps = resolveApps(section.appIds)
if (apps.length === 0) continue
sections.push({...section, apps})
} else if (section.type === 'spotlight') {
// Banners for apps this device doesn't know are dropped, one per app
const banners: SpotlightBanner[] = []
const seen = new Set<string>()
for (const banner of section.banners) {
const app = localAppsKeyed[banner.appId]
if (!app || seen.has(app.id)) continue
seen.add(app.id)
banners.push({app, artwork: banner.artwork})
}
if (banners.length === 0) continue
sections.push({type: 'spotlight', id: section.id, banners})
} else if (section.type === 'category-feature') {
const apps = resolveApps(section.appIds)
const categoryHasLocalApps = (localCategories[section.categoryId]?.length ?? 0) > 0
if (apps.length === 0 || !categoryHasLocalApps) continue
sections.push({...section, apps})
}
}
const featuredByCategory = new Map<string, RegistryApp[]>()
for (const category of storefront.categories) {
const apps = resolveApps(category.featuredAppIds)
if (apps.length > 0) featuredByCategory.set(category.id, apps)
}
return {
sections,
featuredByCategory,
dates: buildAppDates(storefront.apps, localAppsKeyed),
}
}
@@ -0,0 +1,51 @@
import {arrayIncludes} from 'ts-extras'
import {UMBREL_APP_STORE_ID} from '@/constants/app-store'
import {buildAppStatusMap, type AppStoreStatus} from '@/features/app-store/data/catalog'
import {useStoreActions} from '@/features/app-store/providers/store-actions'
import {pollStates} from '@/hooks/use-app-install'
import {useApps} from '@/providers/apps'
import {useAvailableApps} from '@/providers/available-apps'
import {AppStateOrLoading, RegistryApp, trpcReact} from '@/trpc/trpc'
/**
* Presentation-friendly status for every locally available app, derived in one
* pass from queries that are already cached — mounting this on a page with
* hundreds of cards adds zero requests.
*/
export function useAppStatusMap(registryId: string = UMBREL_APP_STORE_ID): Map<string, AppStoreStatus> {
const {userAppsKeyed, isLoading} = useApps()
const availableApps = useAvailableApps(registryId)
if (isLoading || availableApps.isLoading) return new Map()
return buildAppStatusMap(availableApps.apps, userAppsKeyed)
}
export type AppCardState = {state: AppStateOrLoading; progress?: number}
/**
* Live state for a grid in one pass. Idle apps add no per-app query observers;
* only the handful of currently transitioning IDs poll for progress.
*/
export function useAppCardStateMap(apps: readonly RegistryApp[]): Map<string, AppCardState> {
const {userAppsKeyed} = useApps()
const pendingStates = useStoreActions()?.pendingStates
const transitioning = apps.filter((app) => {
const state = pendingStates?.get(app.id) ?? userAppsKeyed?.[app.id]?.state
return state !== undefined && arrayIncludes(pollStates, state)
})
const progressQueries = trpcReact.useQueries((t) =>
transitioning.map((app) => t.apps.state({appId: app.id}, {refetchInterval: 2000})),
)
const states = new Map<string, AppCardState>()
for (const app of apps) {
states.set(app.id, {state: pendingStates?.get(app.id) ?? userAppsKeyed?.[app.id]?.state ?? 'not-installed'})
}
transitioning.forEach((app, index) => {
const state = states.get(app.id)
if (state) state.progress = progressQueries[index]?.data?.progress
})
return states
}
@@ -0,0 +1,79 @@
import {useMotionValue, useTransform, type MotionValue} from 'motion/react'
import {useLayoutEffect, useState} from 'react'
import {useSheetStickyHeader} from '@/providers/sheet-sticky-header'
/**
* Scroll progress (0 → 1 over `distance`px) of a sticky wrapper collapsing
* against the top of the sheet's scroller — shared by the store header and
* the app page hero. Built to be correct at any mount time:
*
* - The scroll position is owned here and synced from the container at mount
* (and again a frame later, after any scroll restoration has applied), so
* chrome mounting into an already-scrolled page renders its correct state
* immediately instead of waiting for the next scroll event.
* - The pin offset is measured from the wrapper's NEXT SIBLING, not the
* wrapper itself: a stuck wrapper's rect is pinned to the viewport and
* reads wrong, but siblings stay in normal flow — subtracting the flex row
* gap and the wrapper's height from the sibling's content offset gives the
* wrapper's natural position in every scroll state.
*/
export function useStickyCollapse(
wrapperRef: React.RefObject<HTMLDivElement | null>,
distance: number,
): MotionValue<number> {
const {scrollRef} = useSheetStickyHeader()
const scrollY = useMotionValue(0)
const [stickStart, setStickStart] = useState(0)
useLayoutEffect(() => {
const scroller = scrollRef?.current
const wrapper = wrapperRef.current
if (!scroller || !wrapper) return
let frame = 0
const sync = () => {
scrollY.set(scroller.scrollTop)
const parent = wrapper.parentElement
const sibling = wrapper.nextElementSibling
if (!parent || !sibling) return
const gap = parseFloat(getComputedStyle(parent).rowGap) || 0
setStickStart(
Math.max(
0,
Math.round(
sibling.getBoundingClientRect().top -
scroller.getBoundingClientRect().top +
scroller.scrollTop -
gap -
wrapper.offsetHeight,
),
),
)
}
const scheduleSync = () => {
if (frame) return
frame = requestAnimationFrame(() => {
frame = 0
sync()
})
}
sync()
// Once more next frame, after any scroll restoration has applied
scheduleSync()
scroller.addEventListener('scroll', scheduleSync, {passive: true})
window.addEventListener('resize', scheduleSync)
// The wrapper's height can change without a scroll (search hides the rail)
const observer = new ResizeObserver(scheduleSync)
observer.observe(wrapper)
return () => {
scroller.removeEventListener('scroll', scheduleSync)
window.removeEventListener('resize', scheduleSync)
observer.disconnect()
cancelAnimationFrame(frame)
}
}, [scrollRef, wrapperRef, scrollY])
return useTransform(scrollY, [stickStart, stickStart + distance], [0, 1], {clamp: true})
}
@@ -0,0 +1,36 @@
import {useDeferredValue, useEffect, useRef, useState} from 'react'
import {useSearchParams} from 'react-router-dom'
/**
* Store search state: the input value, a deferred value driving the results,
* `?q=` kept in sync so back-navigation returns to the results, and a global
* '/' shortcut focusing the input.
*/
export function useStoreSearch() {
const [searchParams, setSearchParams] = useSearchParams()
const [query, setQuery] = useState(searchParams.get('q') ?? '')
const deferredQuery = useDeferredValue(query)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (deferredQuery) searchParams.set('q', deferredQuery)
else searchParams.delete('q')
setSearchParams(searchParams, {replace: true})
}, [deferredQuery])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== '/') return
const target = e.target as HTMLElement
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable)
return
e.preventDefault()
inputRef.current?.focus()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
return {inputRef, query, deferredQuery, setQuery}
}
@@ -0,0 +1,48 @@
import {useQuery} from '@tanstack/react-query'
import {resolveStorefront, type ResolvedStorefront} from '@/features/app-store/data/storefront'
import {storefrontQueryOptions} from '@/features/app-store/data/storefront-query'
import {useAvailableApps} from '@/providers/available-apps'
const emptyStorefront: ResolvedStorefront = {
sections: [],
featuredByCategory: new Map(),
dates: new Map(),
}
/**
* The optional editorial storefront, resolved against the local registry.
* Without feed data (failed, malformed, blocked, offline) this simply returns
* an empty storefront — the complete local experience renders instead.
*
* `isLoading` is true only while the feed's first attempt is in flight (bounded
* by the request timeout). Discover holds its render so it composes once,
* editorial sections and catalog together, instead of the catalog
* appearing alone and the sections landing on top of it a beat later. A feed
* that has already failed is never waited for again: its retry happens in the
* background, and when it succeeds the sections enhance the page in place.
*
* `isUnavailable` is true once the feed has failed or cannot be attempted
* (browser offline) and nothing cached remains to show, letting navigation
* fall back to All apps.
*/
export function useStorefront(): ResolvedStorefront & {isLoading: boolean; isUnavailable: boolean} {
const storefrontQ = useQuery(storefrontQueryOptions())
const availableApps = useAvailableApps()
// react-query reports a retried query as pending (not errored) while the
// retry is in flight; the error count remembers that it has failed before
const hasFailed = storefrontQ.isError || storefrontQ.errorUpdateCount > 0
const isLoading = !storefrontQ.data && !hasFailed && storefrontQ.isLoading
const isUnavailable = !storefrontQ.data && (hasFailed || storefrontQ.isPaused)
if (!storefrontQ.data || availableApps.isLoading || !availableApps.appsKeyed) {
return {...emptyStorefront, isLoading, isUnavailable}
}
return {
...resolveStorefront(storefrontQ.data, availableApps.appsKeyed, availableApps.appsGroupedByCategory ?? {}),
isLoading,
isUnavailable,
}
}
@@ -0,0 +1,46 @@
import {Outlet, useLocation} from 'react-router-dom'
import {SearchResults} from '@/features/app-store/components/search-results'
import {StoreHeader} from '@/features/app-store/components/store-header'
import {UpdatesShelf} from '@/features/app-store/components/updates-shelf'
import {categoryPath, DISCOVER_PATH} from '@/features/app-store/constants'
import {useStoreSearch} from '@/features/app-store/hooks/use-store-search'
import {StoreActionsProvider} from '@/features/app-store/providers/store-actions'
import {trpcReact} from '@/trpc/trpc'
// The store shell: the collapsing header chrome (title, search, owner-only
// updates + community-store controls, category rail — see store-header.tsx),
// then the page content. Search swaps the page content for local results;
// everything else renders through the Outlet.
export default function AppStoreLayout() {
const location = useLocation()
// Members browse the app store read-only; updating apps and managing
// community app stores are owner features
const isOwner = trpcReact.user.get.useQuery().data?.role === 'owner'
const search = useStoreSearch()
return (
<StoreActionsProvider>
<div className='flex flex-col gap-4 md:gap-5'>
<StoreHeader search={search} isOwner={isOwner} />
<div className='flex flex-col gap-4 md:gap-5'>
{/* Maintenance before discovery: updates lead the two landing pages,
Discover and All apps (Discover's stand-in when the feed is
unavailable) — category pages get straight to their grid; the
header's updates chip keeps updates reachable everywhere */}
{!search.deferredQuery && [DISCOVER_PATH, categoryPath('all')].includes(location.pathname) && (
<UpdatesShelf />
)}
{/* Keyed by page identity so switching category (same route, new
params) remounts the content and replays its gentle reveal —
typing within search keeps one stable key */}
<div key={search.deferredQuery ? 'search' : location.pathname} className='flex flex-col gap-4 md:gap-5'>
{search.deferredQuery ? <SearchResults query={search.deferredQuery} /> : <Outlet />}
</div>
</div>
</div>
</StoreActionsProvider>
)
}
@@ -0,0 +1,129 @@
import {createContext, useContext, useState} from 'react'
import {useTranslation} from 'react-i18next'
import {toast} from '@/components/ui/toast'
import {registryAppPath} from '@/constants/app-store'
import {beginAppAction, finishAppAction} from '@/hooks/app-action-guard'
import {useLaunchApp} from '@/hooks/use-launch-app'
import {useUpdateAppMutation} from '@/hooks/use-update-app'
import {getDependencyAlternatives} from '@/modules/app-store/dependency-alternatives'
import {OSUpdateRequiredDialog} from '@/modules/app-store/os-update-required'
import {SelectDependenciesDialog} from '@/modules/app-store/select-dependencies-dialog'
import {canPresentUpdateAction} from '@/modules/app-store/update-availability'
import {useApps} from '@/providers/apps'
import {useAllAvailableApps} from '@/providers/available-apps'
import {AppState, RegistryApp, trpcReact} from '@/trpc/trpc'
type StoreActionsContextT = {
installApp: (app: RegistryApp) => void
updateApp: (app: RegistryApp) => void
openApp: (appId: string) => void
isAppIdAmbiguous: (appId: string) => boolean
pendingStates: ReadonlyMap<string, AppState>
}
const StoreActionsContext = createContext<StoreActionsContextT | null>(null)
/** Null when no provider is mounted — callers fall back to passive labels */
export function useStoreActions() {
return useContext(StoreActionsContext)
}
/**
* One shared install/update/open flow for every app card in the subtree, so
* hundreds of cards can offer working actions without each mounting its own
* dialogs. Runs the same gates as the app page's install button: members are
* pointed to the owner, incompatible apps explain the required OS update, and
* dependencies are chosen before installing.
*/
export function StoreActionsProvider({children}: {children: React.ReactNode}) {
const {t} = useTranslation()
const isMember = trpcReact.user.get.useQuery().data?.role === 'member'
const {apps, ambiguousAppIds} = useAllAvailableApps()
const {userAppsKeyed} = useApps()
const launchApp = useLaunchApp()
const utils = trpcReact.useUtils()
const [dialog, setDialog] = useState<{kind: 'os-update' | 'dependencies'; app: RegistryApp} | null>(null)
const [pendingStates, setPendingStates] = useState<ReadonlyMap<string, AppState>>(new Map())
const setPendingState = (appId: string, state?: AppState) => {
setPendingStates((current) => {
const next = new Map(current)
if (state) next.set(appId, state)
else next.delete(appId)
return next
})
}
const installMut = trpcReact.apps.install.useMutation({
// Optimistic seeding: the mutation doesn't return until the install
// completes, so make the transition visible immediately
onMutate: ({appId}) => {
setPendingState(appId, 'installing')
utils.apps.state.cancel()
utils.apps.state.setData({appId}, {state: 'installing', progress: 0})
setTimeout(() => utils.apps.list.invalidate(), 2000)
},
onSettled: (_data, _error, {appId}) => {
finishAppAction(appId)
setPendingState(appId)
utils.apps.state.invalidate({appId})
utils.apps.list.invalidate()
utils.user.get.invalidate()
},
})
// The same optimistic seeding and refresh as every other update surface
const updateMut = useUpdateAppMutation()
const installApp = (app: RegistryApp) => {
if (isMember) return void toast(t('app-store.ask-owner-to-install'), {area: 'app-store'})
if (ambiguousAppIds?.has(app.id)) return void toast(t('app-store.app-id-conflict'), {area: 'app-store'})
if (!app.compatible) return setDialog({kind: 'os-update', app})
if (app.dependencies && app.dependencies.length > 0) return setDialog({kind: 'dependencies', app})
if (beginAppAction(app.id)) installMut.mutate({appId: app.id})
}
const updateApp = (app: RegistryApp) => {
if (isMember) return void toast(t('app-store.ask-owner-to-update'), {area: 'app-store'})
if (ambiguousAppIds?.has(app.id)) return void toast(t('app-store.app-id-conflict'), {area: 'app-store'})
const state = userAppsKeyed?.[app.id]?.state ?? 'not-installed'
if (!canPresentUpdateAction(state)) return
if (!app.compatible) return setDialog({kind: 'os-update', app})
updateMut.mutate({appId: app.id})
}
const closeDialog = (open: boolean) => {
if (!open) setDialog(null)
}
const installWithDependencies = (appId: string, alternatives: Record<string, string>) => {
if (ambiguousAppIds?.has(appId)) return void toast(t('app-store.app-id-conflict'), {area: 'app-store'})
if (beginAppAction(appId)) installMut.mutate({appId, alternatives})
}
return (
<StoreActionsContext
value={{
installApp,
updateApp,
openApp: launchApp,
isAppIdAmbiguous: (appId) => ambiguousAppIds?.has(appId) ?? false,
pendingStates,
}}
>
{children}
{dialog?.kind === 'os-update' && <OSUpdateRequiredDialog app={dialog.app} open onOpenChange={closeDialog} />}
{dialog?.kind === 'dependencies' && (
<SelectDependenciesDialog
appId={dialog.app.id}
dependencies={getDependencyAlternatives(dialog.app.dependencies, apps ?? [], userAppsKeyed, ambiguousAppIds)}
open
onOpenChange={closeDialog}
onNext={(selectedDeps) => installWithDependencies(dialog.app.id, selectedDeps)}
onInstallDependency={installApp}
makeDependencyPath={registryAppPath}
/>
)}
</StoreActionsContext>
)
}
@@ -0,0 +1,36 @@
import React from 'react'
import {RouteObject} from 'react-router-dom'
import {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'
const AppStoreLayout = React.lazy(() => import('@/features/app-store'))
const Discover = React.lazy(() => import('@/features/app-store/components/discover'))
const Category = React.lazy(() => import('@/features/app-store/components/category'))
const AppPage = React.lazy(() => import('@/features/app-store/components/app-page'))
// Mounted inside SheetLayout. The app page is a sibling of the store shell so
// it renders without the store header/search chrome, like a pushed detail view.
export const appStoreRoutes: RouteObject[] = [
{
path: 'app-store',
element: <AppStoreLayout />,
ErrorBoundary: ErrorBoundaryCardFallback,
children: [
{
index: true,
element: <Discover />,
ErrorBoundary: ErrorBoundaryCardFallback,
},
{
path: 'category/:categoryId',
element: <Category />,
ErrorBoundary: ErrorBoundaryCardFallback,
},
],
},
{
path: 'app-store/:appId',
element: <AppPage />,
ErrorBoundary: ErrorBoundaryCardFallback,
},
]
@@ -2,8 +2,8 @@ import {Expand, Loader2, Minimize2, Power, RotateCw, Shrink} from 'lucide-react'
import {useEffect, useState} from 'react'
import {useParams} from 'react-router-dom'
import {DarkTooltip} from '@/components/ui/dark-tooltip'
import {MachineDisplay} from '@/features/machines/components/machine-display'
import {MachinesTooltip} from '@/features/machines/components/machines-tooltip'
import {OsIcon} from '@/features/machines/components/os-icon'
import {machinePath, machineStopTextClass} from '@/features/machines/constants'
import {useMachineActions} from '@/features/machines/hooks/use-machine-actions'
@@ -74,7 +74,7 @@ export default function FullscreenConsole() {
<span className='text-13 -tracking-2 text-white/70'>{t('machines.umbrel-machines')}</span>
</div>
<div className='flex items-center gap-1'>
<MachinesTooltip label={t('machines.fullscreen')} side='bottom'>
<DarkTooltip label={t('machines.fullscreen')} side='bottom'>
<button
className={consoleButtonClass}
onClick={toggleBrowserFullscreen}
@@ -82,13 +82,13 @@ export default function FullscreenConsole() {
>
{isBrowserFullscreen ? <Shrink className='size-4' /> : <Expand className='size-4' />}
</button>
</MachinesTooltip>
<MachinesTooltip label={t('machines.exit-fullscreen')} side='bottom'>
</DarkTooltip>
<DarkTooltip label={t('machines.exit-fullscreen')} side='bottom'>
<button className={consoleButtonClass} onClick={exitFullscreen} aria-label={t('machines.exit-fullscreen')}>
<Minimize2 className='size-4' />
</button>
</MachinesTooltip>
<MachinesTooltip label={t('machines.restart')} side='bottom'>
</DarkTooltip>
<DarkTooltip label={t('machines.restart')} side='bottom'>
<button
className={consoleButtonClass}
onClick={() => restart({id: machine.id})}
@@ -97,11 +97,8 @@ export default function FullscreenConsole() {
>
<RotateCw className='size-4' />
</button>
</MachinesTooltip>
<MachinesTooltip
label={isBusy ? t(`machines.state.${machine.state}`) : t('machines.shut-down')}
side='bottom'
>
</DarkTooltip>
<DarkTooltip label={isBusy ? t(`machines.state.${machine.state}`) : t('machines.shut-down')} side='bottom'>
<button
className={consoleButtonClass}
onClick={() => stop({id: machine.id})}
@@ -114,7 +111,7 @@ export default function FullscreenConsole() {
<Power className={cn('size-4', machineStopTextClass)} />
)}
</button>
</MachinesTooltip>
</DarkTooltip>
</div>
</header>
<div className='relative min-h-0 flex-1'>
@@ -1,7 +1,7 @@
import {Loader2, Maximize2, Pin, Power, RotateCw, Volume2, VolumeX, X} from 'lucide-react'
import {DarkTooltip} from '@/components/ui/dark-tooltip'
import {MachineMenu, useUninstallMachine} from '@/features/machines/components/machines-list'
import {MachinesTooltip} from '@/features/machines/components/machines-tooltip'
import {machineFullscreenPath, machineRailButtonClass, machineStopTextClass} from '@/features/machines/constants'
import {useMachineActions} from '@/features/machines/hooks/use-machine-actions'
import {useMachineAudioPreference} from '@/features/machines/hooks/use-machine-audio-preference'
@@ -23,7 +23,7 @@ export function MachineRail({machine}: {machine: Machine}) {
// wraps below the display on smaller screens it just fades in
className='flex w-full shrink-0 animate-in flex-row flex-wrap justify-center gap-3 delay-150 duration-300 fill-mode-backwards fade-in xl:w-auto xl:flex-col xl:flex-nowrap xl:justify-start xl:slide-in-from-right-6'
>
<MachinesTooltip label={t('machines.open-fullscreen')} side='left'>
<DarkTooltip label={t('machines.open-fullscreen')} side='left'>
<a
href={machineFullscreenPath(machine.id)}
target='_blank'
@@ -33,11 +33,8 @@ export function MachineRail({machine}: {machine: Machine}) {
>
<Maximize2 className='size-5' />
</a>
</MachinesTooltip>
<MachinesTooltip
label={muted ? t('machines.console-enable-audio') : t('machines.console-mute-audio')}
side='left'
>
</DarkTooltip>
<DarkTooltip label={muted ? t('machines.console-enable-audio') : t('machines.console-mute-audio')} side='left'>
<button
className={cn(machineRailButtonClass, muted && 'text-white/45')}
onClick={() => setMuted(!muted)}
@@ -45,8 +42,8 @@ export function MachineRail({machine}: {machine: Machine}) {
>
{muted ? <VolumeX className='size-5' /> : <Volume2 className='size-5' />}
</button>
</MachinesTooltip>
<MachinesTooltip
</DarkTooltip>
<DarkTooltip
label={machine.pinned ? t('machines.unpin-from-homescreen') : t('machines.pin-to-homescreen')}
side='left'
>
@@ -57,8 +54,8 @@ export function MachineRail({machine}: {machine: Machine}) {
>
<Pin className={cn('size-5', machine.pinned && 'fill-current')} />
</button>
</MachinesTooltip>
<MachinesTooltip label={t('machines.restart')} side='left'>
</DarkTooltip>
<DarkTooltip label={t('machines.restart')} side='left'>
<button
className={machineRailButtonClass}
onClick={() => restart({id: machine.id})}
@@ -67,13 +64,13 @@ export function MachineRail({machine}: {machine: Machine}) {
>
<RotateCw className='size-5' />
</button>
</MachinesTooltip>
</DarkTooltip>
{machine.state === 'installing' ? (
<>
<button className={machineRailButtonClass} disabled aria-label={t('machines.state.installing')}>
<Loader2 className='size-5 animate-spin' />
</button>
<MachinesTooltip label={t('machines.cancel-install')} side='left'>
<DarkTooltip label={t('machines.cancel-install')} side='left'>
<button
className={machineRailButtonClass}
onClick={cancelInstall}
@@ -81,10 +78,10 @@ export function MachineRail({machine}: {machine: Machine}) {
>
<X className={cn('size-5', machineStopTextClass)} />
</button>
</MachinesTooltip>
</DarkTooltip>
</>
) : machine.state === 'stopped' || machine.state === 'error' ? (
<MachinesTooltip
<DarkTooltip
label={
machine.state === 'error' && machine.installPending
? t('machines.retry-install')
@@ -107,9 +104,9 @@ export function MachineRail({machine}: {machine: Machine}) {
>
<Power className='size-5' />
</button>
</MachinesTooltip>
</DarkTooltip>
) : (
<MachinesTooltip label={isBusy ? t(`machines.state.${machine.state}`) : t('machines.shut-down')} side='left'>
<DarkTooltip label={isBusy ? t(`machines.state.${machine.state}`) : t('machines.shut-down')} side='left'>
<button
className={machineRailButtonClass}
onClick={() => stop({id: machine.id})}
@@ -122,7 +119,7 @@ export function MachineRail({machine}: {machine: Machine}) {
<Power className={cn('size-5', machineStopTextClass)} />
)}
</button>
</MachinesTooltip>
</DarkTooltip>
)}
<MachineMenu machine={machine} buttonClassName={machineRailButtonClass} />
</div>
@@ -2,10 +2,10 @@ import {Disc3, Loader2, MoreHorizontal, Pin, PinOff, Power, RotateCw, Settings,
import {AnimatePresence, motion} from 'motion/react'
import {useNavigate} from 'react-router-dom'
import {DarkTooltip} from '@/components/ui/dark-tooltip'
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'
import {Progress} from '@/components/ui/progress'
import {toast} from '@/components/ui/toast'
import {MachinesTooltip} from '@/features/machines/components/machines-tooltip'
import {machineIconSrc, OsIcon} from '@/features/machines/components/os-icon'
import {
getOsVisuals,
@@ -182,7 +182,7 @@ function MachinePowerButton({machine}: {machine: Machine}) {
if (machine.state === 'running') {
return (
<MachinesTooltip label={t('machines.shut-down')}>
<DarkTooltip label={t('machines.shut-down')}>
<button
className={machineRowButtonClass}
onClick={() => stop({id: machine.id})}
@@ -190,7 +190,7 @@ function MachinePowerButton({machine}: {machine: Machine}) {
>
<Power className={cn('size-4 md:size-5', machineStopTextClass)} />
</button>
</MachinesTooltip>
</DarkTooltip>
)
}
@@ -203,7 +203,7 @@ function MachinePowerButton({machine}: {machine: Machine}) {
? t('machines.turn-on-again')
: t('machines.turn-on')
return (
<MachinesTooltip label={label}>
<DarkTooltip label={label}>
<button
className={machineRowButtonClass}
onClick={() => (retryingInstall ? retryInstall({id: machine.id}) : start({id: machine.id}))}
@@ -211,7 +211,7 @@ function MachinePowerButton({machine}: {machine: Machine}) {
>
<Power className='size-4 md:size-5' />
</button>
</MachinesTooltip>
</DarkTooltip>
)
}
@@ -305,13 +305,13 @@ export function MachineMenu({
return (
<DropdownMenu>
<MachinesTooltip label={t('machines.machine-options')}>
<DarkTooltip label={t('machines.machine-options')}>
<DropdownMenuTrigger asChild>
<button className={buttonClassName ?? machineRowButtonClass} aria-label={t('machines.machine-options')}>
<MoreHorizontal className='size-4 md:size-5' />
</button>
</DropdownMenuTrigger>
</MachinesTooltip>
</DarkTooltip>
{/* p-1: match the homescreen context menu's tight padding */}
<DropdownMenuContent align='start' className='w-60 p-1'>
{/* Block cards share the menu items' radius so they nest concentrically
@@ -3,7 +3,7 @@ import {motion} from 'motion/react'
import {useEffect, useRef} from 'react'
import {NavLink, useLocation} from 'react-router-dom'
import {MachinesTooltip} from '@/features/machines/components/machines-tooltip'
import {DarkTooltip} from '@/components/ui/dark-tooltip'
import {OsIcon} from '@/features/machines/components/os-icon'
import {layoutMorphTransition, machinePath, MACHINES_ADD_PATH, MACHINES_PATH} from '@/features/machines/constants'
import type {Machine} from '@/features/machines/types'
@@ -76,16 +76,16 @@ export function MachinesTabBar({machines}: {machines: Machine[]}) {
// to a visual 12px
className='-mt-1 -mr-2 -mb-3 flex items-center gap-2 overflow-x-auto py-1 pr-2 [scrollbar-width:none] md:mr-0 md:pr-0 [&::-webkit-scrollbar]:hidden'
>
<MachinesTooltip label={t('machines.all-machines')} side='bottom'>
<DarkTooltip label={t('machines.all-machines')} side='bottom'>
<Tab to={MACHINES_PATH} end label={t('machines.all-machines')} className='w-10'>
<LayoutGrid className='size-5' />
</Tab>
</MachinesTooltip>
<MachinesTooltip label={t('machines.add-machine')} side='bottom'>
</DarkTooltip>
<DarkTooltip label={t('machines.add-machine')} side='bottom'>
<Tab to={MACHINES_ADD_PATH} label={t('machines.add-machine')} className='w-10'>
<Plus className='size-5' />
</Tab>
</MachinesTooltip>
</DarkTooltip>
{machines.map((machine) => (
<Tab key={machine.id} to={machinePath(machine.id)} className='animate-in px-3 duration-300 fade-in'>
<OsIcon osId={machine.osId} state={machine.state} className='size-6' />

Some files were not shown because too many files have changed in this diff Show More