feat: show error popups with a reason for failed avatar pack/addon adds

Avatar pack discovery used to return an empty array on every failure
path (bad URL, repo not found, no packs, no valid images), so a failed
add looked identical to nothing happening. It now throws a typed
AvatarPackDiscoveryError and the settings screen shows it in a Toast.

Also wires up the new profileAvatarPackManifestPlan core route so a
direct pack.json link (GitHub or any other host) can be added without
needing the whole-repository discovery flow.

Addon installation already surfaced its error inline; it now also
shows a Toast popup with the failure reason.
This commit is contained in:
KhooLy 2026-07-31 00:43:55 +03:00
parent 15ca2b3e02
commit a1a338ffa2
6 changed files with 97 additions and 6 deletions

View file

@ -282,6 +282,7 @@ export const CORE_METHODS = [
'profileAvatarDefault',
'profileAvatarPackCatalog',
'profileAvatarPackDiscoveryPlan',
'profileAvatarPackManifestPlan',
'profileAvatarPackParse',
'profileAvatarPackRepositoryPlan',
'profileConnectionState',

View file

@ -21,6 +21,15 @@ export interface ProfilePickerSettings {
avatarPacks: ProfileAvatarPack[];
}
export type AvatarPackDiscoveryReason = 'invalid_url' | 'repository_not_found' | 'no_packs_found' | 'no_valid_avatars';
export class AvatarPackDiscoveryError extends Error {
constructor(public reason: AvatarPackDiscoveryReason) {
super(reason);
this.name = 'AvatarPackDiscoveryError';
}
}
export async function loadProfilePickerSettings(): Promise<ProfilePickerSettings> {
const stored = await storageRead<Partial<ProfilePickerSettings>>(STORAGE_KEY);
return {
@ -33,24 +42,42 @@ export async function saveProfilePickerSettings(settings: ProfilePickerSettings)
await storageWrite(STORAGE_KEY, settings);
}
async function discoverDirectManifestPack(repositoryUrl: string): Promise<ProfileAvatarPack | null> {
const manifestPlan = await coreInvoke<{ manifestUrl: string } | null>(
'profileAvatarPackManifestPlan',
JSON.stringify({ repositoryUrl }),
);
if (!manifestPlan) return null;
const pack = await fetchJson(manifestPlan.manifestUrl);
const parsed = await coreInvoke<{ title: string; manifestUrl: string; avatars: ProfileAvatar[] } | null>(
'profileAvatarPackParse',
JSON.stringify({ manifestUrl: manifestPlan.manifestUrl, pack }),
);
if (!parsed || !parsed.avatars.length) throw new AvatarPackDiscoveryError('no_valid_avatars');
return { ...parsed, id: parsed.manifestUrl, repositoryUrl };
}
export async function discoverProfileAvatarPacks(repositoryUrl: string): Promise<ProfileAvatarPack[]> {
const directPack = await discoverDirectManifestPack(repositoryUrl);
if (directPack) return [directPack];
const repositoryPlan = await coreInvoke<{ repositoryApiUrl: string } | null>(
'profileAvatarPackRepositoryPlan',
JSON.stringify({ repositoryUrl }),
);
if (!repositoryPlan) return [];
if (!repositoryPlan) throw new AvatarPackDiscoveryError('invalid_url');
const repository = await fetchJson(repositoryPlan.repositoryApiUrl);
const discoveryPlan = await coreInvoke<{ reference: string; treeApiUrl: string } | null>(
'profileAvatarPackDiscoveryPlan',
JSON.stringify({ repositoryUrl, repository }),
);
if (!discoveryPlan) return [];
if (!discoveryPlan) throw new AvatarPackDiscoveryError('repository_not_found');
const tree = await fetchJson(discoveryPlan.treeApiUrl);
const catalog = await coreInvoke<{ categories: Array<{ manifestUrl: string }> } | null>(
'profileAvatarPackCatalog',
JSON.stringify({ repositoryUrl, reference: discoveryPlan.reference, tree }),
);
if (!catalog?.categories.length) return [];
if (!catalog?.categories.length) throw new AvatarPackDiscoveryError('no_packs_found');
const packs = await Promise.all(catalog.categories.map(async ({ manifestUrl }) => {
const pack = await fetchJson(manifestUrl);
const parsed = await coreInvoke<{ title: string; manifestUrl: string; avatars: ProfileAvatar[] } | null>(
@ -61,7 +88,9 @@ export async function discoverProfileAvatarPacks(repositoryUrl: string): Promise
? { ...parsed, id: parsed.manifestUrl, repositoryUrl }
: null;
}));
return packs.filter((pack): pack is ProfileAvatarPack => pack !== null);
const found = packs.filter((pack): pack is ProfileAvatarPack => pack !== null);
if (!found.length) throw new AvatarPackDiscoveryError('no_valid_avatars');
return found;
}
export async function refreshAvatarPackRepository(repositoryUrl: string): Promise<ProfilePickerSettings> {

View file

@ -866,6 +866,8 @@
"addons.uptime_24h": "24h uptime",
"addons.installed_dialog_title": "Addon Added",
"addons.installed_dialog_body": "\"%s\" has been added to your addons.",
"addons.install_failed_title": "Couldn't install addon",
"addons.install_failed_message": "The manifest URL couldn't be loaded or parsed.",
"plugins.title": "Plugins",
"plugins.subtitle": "Plugin repositories and scrapers",
"plugins.install": "Install Plugin Repository",
@ -1279,13 +1281,19 @@
"profiles.picker_background_placeholder": "Background image URL",
"profiles.clear_background": "Clear background",
"profiles.avatar_packs": "Profile image packs",
"profiles.avatar_packs_desc": "Add a GitHub repository containing profile image packs.",
"profiles.avatar_packs_desc": "Add a repository or pack link to import profile images.",
"profiles.avatar_pack_repository_placeholder": "https://github.com/owner/repository",
"profiles.add_pack": "Add pack",
"profiles.remove_pack": "Remove pack",
"profiles.refresh_pack": "Refresh pack",
"profiles.no_avatar_packs": "No profile image packs have been added.",
"profiles.avatar_pack_count": "%s profile images",
"profiles.avatar_pack_error_title": "Couldn't add profile image pack",
"profiles.avatar_pack_error_invalid_url": "That doesn't look like a GitHub repository URL.",
"profiles.avatar_pack_error_repository_not_found": "Couldn't find that repository on GitHub.",
"profiles.avatar_pack_error_no_packs_found": "No pack.json files were found in that repository.",
"profiles.avatar_pack_error_no_valid_avatars": "The pack.json files found didn't contain any usable images.",
"profiles.avatar_pack_error_generic": "Something went wrong while adding this pack.",
"detail.similar_source": "Recommendations from",
"detail.similar_source_auto": "Automatic",
"detail.similar_source_trakt": "Trakt Recommendations",

View file

@ -866,6 +866,8 @@
"addons.uptime_24h": "24 sa çalışma",
"addons.installed_dialog_title": "Addon Eklendi",
"addons.installed_dialog_body": "\"%s\" addonlarınıza eklendi.",
"addons.install_failed_title": "Addon kurulamadı",
"addons.install_failed_message": "Manifest URL'si yüklenemedi veya ayrıştırılamadı.",
"plugins.title": "Pluginler",
"plugins.subtitle": "Plugin depoları ve scraperlar",
"plugins.install": "Plugin Deposu Kur",
@ -1279,13 +1281,19 @@
"profiles.picker_background_placeholder": "Arka plan görseli URL'si",
"profiles.clear_background": "Arka planı temizle",
"profiles.avatar_packs": "Profil görseli paketleri",
"profiles.avatar_packs_desc": "Profil görseli paketleri içeren bir GitHub deposu ekleyin.",
"profiles.avatar_packs_desc": "Profil görseli almak için bir depo veya paket linki ekleyin.",
"profiles.avatar_pack_repository_placeholder": "https://github.com/sahip/depo",
"profiles.add_pack": "Paket ekle",
"profiles.remove_pack": "Paketi kaldır",
"profiles.refresh_pack": "Paketi yenile",
"profiles.no_avatar_packs": "Henüz profil görseli paketi eklenmedi.",
"profiles.avatar_pack_count": "%s profil görseli",
"profiles.avatar_pack_error_title": "Profil görseli paketi eklenemedi",
"profiles.avatar_pack_error_invalid_url": "Bu bir GitHub deposu URL'sine benzemiyor.",
"profiles.avatar_pack_error_repository_not_found": "Bu depo GitHub'da bulunamadı.",
"profiles.avatar_pack_error_no_packs_found": "Bu depoda pack.json dosyası bulunamadı.",
"profiles.avatar_pack_error_no_valid_avatars": "Bulunan pack.json dosyalarında kullanılabilir görsel yoktu.",
"profiles.avatar_pack_error_generic": "Bu paket eklenirken bir sorun oluştu.",
"detail.similar_source": "Öneri kaynağı",
"detail.similar_source_auto": "Otomatik",
"detail.similar_source_trakt": "Trakt Önerileri",

View file

@ -1,13 +1,29 @@
import React, { useRef, useState } from 'react';
import { ImagePlus, Plus, RefreshCw, Trash2 } from 'lucide-react';
import {
AvatarPackDiscoveryError,
discoverProfileAvatarPacks,
refreshAvatarPackRepository,
type ProfilePickerSettings,
saveProfilePickerSettings,
} from '../core/profileAvatarPacks';
import { Toast } from '../components/Toast';
import { t } from '../i18n';
const AVATAR_PACK_ERROR_MESSAGE_KEYS: Record<string, string> = {
invalid_url: 'profiles.avatar_pack_error_invalid_url',
repository_not_found: 'profiles.avatar_pack_error_repository_not_found',
no_packs_found: 'profiles.avatar_pack_error_no_packs_found',
no_valid_avatars: 'profiles.avatar_pack_error_no_valid_avatars',
};
function avatarPackErrorMessage(error: unknown): string {
if (error instanceof AvatarPackDiscoveryError) {
return t(AVATAR_PACK_ERROR_MESSAGE_KEYS[error.reason] ?? 'profiles.avatar_pack_error_generic');
}
return t('profiles.avatar_pack_error_generic');
}
export function ProfilePickerSettings({
settings,
onSaved,
@ -22,6 +38,7 @@ export function ProfilePickerSettings({
const [packs, setPacks] = useState(settings.avatarPacks);
const [busy, setBusy] = useState(false);
const [refreshingRepo, setRefreshingRepo] = useState<string | null>(null);
const [avatarPackError, setAvatarPackError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const save = async (next: ProfilePickerSettings) => {
@ -39,6 +56,8 @@ export function ProfilePickerSettings({
setPacks(nextPacks);
setRepositoryUrl('');
await save({ backgroundUrl: backgroundUrl || undefined, avatarPacks: nextPacks });
} catch (error) {
setAvatarPackError(avatarPackErrorMessage(error));
} finally {
setBusy(false);
}
@ -51,6 +70,8 @@ export function ProfilePickerSettings({
const next = await refreshAvatarPackRepository(repositoryUrl);
setPacks(next.avatarPacks);
onSaved(next);
} catch (error) {
setAvatarPackError(avatarPackErrorMessage(error));
} finally {
setRefreshingRepo(null);
}
@ -107,6 +128,16 @@ export function ProfilePickerSettings({
</div>
<button onClick={onBack} style={S.backButton}>{t('common.back')}</button>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={uploadBackground} />
{avatarPackError && (
<div style={{ position: 'fixed', top: '1rem', right: '1rem', zIndex: 100 }}>
<Toast
variant="error"
title={t('profiles.avatar_pack_error_title')}
message={avatarPackError}
onClose={() => setAvatarPackError(null)}
/>
</div>
)}
</section>
);
}

View file

@ -46,6 +46,7 @@ import { AddonsSection } from '../components/settings/AddonsSection';
import { PluginsSection } from '../components/settings/PluginsSection';
import { DownloadsSection } from '../components/settings/DownloadsSection';
import { AddonAddedDialog } from '../components/AddonAddedDialog';
import { Toast } from '../components/Toast';
async function settingsFetchJson(url: string): Promise<unknown> {
const response = await httpFetchText(url);
@ -487,6 +488,19 @@ export function SettingsScreen({ state, onDispatch, activeProfile, onProfileUpda
{addedAddonName && (
<AddonAddedDialog addonName={addedAddonName} onConfirm={() => setAddedAddonName(null)} />
)}
{addonInstallStatus.error && (
<div style={{ position: 'fixed', top: '1rem', right: '1rem', zIndex: 100 }}>
<Toast
variant="error"
title={t('addons.install_failed_title')}
message={t('addons.install_failed_message')}
details={addonInstallStatus.error}
detailsLabel={t('player.error_show_details')}
detailsHideLabel={t('player.error_hide_details')}
onClose={() => setAddonInstallStatus((prev) => ({ ...prev, error: null }))}
/>
</div>
)}
</div>
);
}