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.
@@ -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.
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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の言語",
|
||||
|
||||
@@ -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 언어",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 語言",
|
||||
|
||||
@@ -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 语言",
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
@@ -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]},
|
||||
}
|
||||
@@ -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' />
|
||||
|
||||