feat(ui): Add spinner for "Downloading..." element in UI and add save icon in config page

This commit is contained in:
Xoconoch
2025-08-23 23:22:11 -06:00
parent 0661865d16
commit 690e6b0a18
16 changed files with 86 additions and 44 deletions

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1716 1C18.702 1 19.2107 1.21071 19.5858 1.58579L22.4142 4.41421C22.7893 4.78929 23 5.29799 23 5.82843V20C23 21.6569 21.6569 23 20 23H4C2.34315 23 1 21.6569 1 20V4C1 2.34315 2.34315 1 4 1H18.1716ZM4 3C3.44772 3 3 3.44772 3 4V20C3 20.5523 3.44772 21 4 21L5 21L5 15C5 13.3431 6.34315 12 8 12L16 12C17.6569 12 19 13.3431 19 15V21H20C20.5523 21 21 20.5523 21 20V6.82843C21 6.29799 20.7893 5.78929 20.4142 5.41421L18.5858 3.58579C18.2107 3.21071 17.702 3 17.1716 3H17V5C17 6.65685 15.6569 8 14 8H10C8.34315 8 7 6.65685 7 5V3H4ZM17 21V15C17 14.4477 16.5523 14 16 14L8 14C7.44772 14 7 14.4477 7 15L7 21L17 21ZM9 3H15V5C15 5.55228 14.5523 6 14 6H10C9.44772 6 9 5.55228 9 5V3Z" fill="#0F0F0F"/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13 2a1 1 0 0 0-2 0v4.167a1 1 0 1 0 2 0V2ZM13 17.833a1 1 0 0 0-2 0V22a1 1 0 1 0 2 0v-4.167ZM16.834 12a1 1 0 0 1 1-1H22a1 1 0 0 1 0 2h-4.166a1 1 0 0 1-1-1ZM2 11a1 1 0 0 0 0 2h4.167a1 1 0 1 0 0-2H2ZM19.916 4.085a1 1 0 0 1 0 1.414l-2.917 2.917A1 1 0 1 1 15.585 7l2.917-2.916a1 1 0 0 1 1.414 0ZM8.415 16.999a1 1 0 0 0-1.414-1.414L4.084 18.5A1 1 0 1 0 5.5 19.916l2.916-2.917ZM15.585 15.585a1 1 0 0 1 1.414 0l2.917 2.916a1 1 0 1 1-1.414 1.415l-2.917-2.917a1 1 0 0 1 0-1.414ZM5.499 4.085a1 1 0 0 0-1.415 1.414l2.917 2.917A1 1 0 0 0 8.415 7L5.5 4.085Z" fill="#000000"/></svg>

After

Width:  |  Height:  |  Size: 796 B

View File

@@ -54,7 +54,7 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
? "Queued." ? "Queued."
: status === "error" : status === "error"
? <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" /> ? <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
: "Downloading..." : <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin" />
: <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" /> : <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
} }
</button> </button>

View File

@@ -65,7 +65,7 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
? "Queued." ? "Queued."
: status === "error" : status === "error"
? <img src="/download.svg" alt="Download" className="w-5 h-5 logo" /> ? <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
: "Downloading..." : <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin" />
: <img src="/download.svg" alt="Download" className="w-5 h-5 logo" /> : <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
} }
</button> </button>

View File

@@ -174,8 +174,13 @@ export function AccountsTab() {
type="submit" type="submit"
disabled={addMutation.isPending} disabled={addMutation.isPending}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Account"
> >
{addMutation.isPending ? "Saving..." : "Save Account"} {addMutation.isPending ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
)}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -100,8 +100,8 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
queryClient.invalidateQueries({ queryKey: ["config"] }); queryClient.invalidateQueries({ queryKey: ["config"] });
}, },
onError: (error) => { onError: (error) => {
console.error("Failed to save settings", error.message); console.error("Failed to save settings", (error as any).message);
toast.error(`Failed to save settings: ${error.message}`); toast.error(`Failed to save settings: ${(error as any).message}`);
}, },
}); });
@@ -180,8 +180,13 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
type="submit" type="submit"
disabled={mutation.isPending || !!validationError} disabled={mutation.isPending || !!validationError}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Download Settings"
> >
{mutation.isPending ? "Saving..." : "Save Download Settings"} {mutation.isPending ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
)}
</button> </button>
</div> </div>
</div> </div>
@@ -359,7 +364,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
type="number" type="number"
min="1" min="1"
{...register("retryDelaySeconds")} {...register("retryDelaySeconds")}
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus" className="block w_full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -369,7 +374,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
type="number" type="number"
min="0" min="0"
{...register("retryDelayIncrease")} {...register("retryDelayIncrease")}
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline_none focus:ring-2 focus:ring-input-focus"
/> />
</div> </div>
</div> </div>

View File

@@ -88,8 +88,8 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
queryClient.invalidateQueries({ queryKey: ["config"] }); queryClient.invalidateQueries({ queryKey: ["config"] });
}, },
onError: (error) => { onError: (error) => {
console.error("Failed to save formatting settings:", error.message); console.error("Failed to save formatting settings:", (error as any).message);
toast.error(`Failed to save settings: ${error.message}`); toast.error(`Failed to save settings: ${(error as any).message}`);
}, },
}); });
@@ -131,8 +131,13 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Formatting Settings"
> >
{mutation.isPending ? "Saving..." : "Save Formatting Settings"} {mutation.isPending ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -83,8 +83,13 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save General Settings"
> >
{mutation.isPending ? "Saving..." : "Save General Settings"} {mutation.isPending ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -126,7 +126,7 @@ export function ProfileTab() {
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1"> <label className="block text_sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Role Role
</label> </label>
<p className="text-content-primary dark:text-content-primary-dark"> <p className="text-content-primary dark:text-content-primary-dark">
@@ -177,7 +177,7 @@ export function ProfileTab() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-2"> <label className="block text-sm font_medium text-content-secondary dark:text-content-secondary-dark mb-2">
New Password New Password
</label> </label>
<input <input
@@ -226,8 +226,13 @@ export function ProfileTab() {
type="submit" type="submit"
disabled={isChangingPassword} disabled={isChangingPassword}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save Password"
> >
{isChangingPassword ? "Changing Password..." : "Change Password"} {isChangingPassword ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin inline-block logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 inline-block logo" />
)}
</button> </button>
<button <button
type="button" type="button"
@@ -252,7 +257,7 @@ export function ProfileTab() {
{/* SSO User Notice */} {/* SSO User Notice */}
{user?.is_sso_user && ( {user?.is_sso_user && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6"> <div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<h3 className="text-lg font-medium text-blue-900 dark:text-blue-100 mb-2"> <h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
SSO Account SSO Account
</h3> </h3>
<p className="text-blue-800 dark:text-blue-200"> <p className="text-blue-800 dark:text-blue-200">

View File

@@ -54,8 +54,8 @@ function SpotifyApiForm() {
queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] }); queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] });
}, },
onError: (e) => { onError: (e) => {
console.error("Failed to save Spotify API settings:", e.message); console.error("Failed to save Spotify API settings:", (e as any).message);
toast.error(`Failed to save: ${e.message}`); toast.error(`Failed to save: ${(e as any).message}`);
}, },
}); });
@@ -75,8 +75,13 @@ function SpotifyApiForm() {
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Spotify API"
> >
{mutation.isPending ? "Saving..." : "Save Spotify API"} {mutation.isPending ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
)}
</button> </button>
</div> </div>
</div> </div>
@@ -119,7 +124,7 @@ function WebhookForm() {
queryClient.invalidateQueries({ queryKey: ["webhookConfig"] }); queryClient.invalidateQueries({ queryKey: ["webhookConfig"] });
}, },
onError: (e) => { onError: (e) => {
toast.error(`Failed to save: ${e.message}`); toast.error(`Failed to save: ${(e as any).message}`);
}, },
}); });
@@ -128,7 +133,7 @@ function WebhookForm() {
onSuccess: () => { onSuccess: () => {
// No toast needed // No toast needed
}, },
onError: (e) => toast.error(`Webhook test failed: ${e.message}`), onError: (e) => toast.error(`Webhook test failed: ${(e as any).message}`),
}); });
useEffect(() => { useEffect(() => {
@@ -147,8 +152,13 @@ function WebhookForm() {
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Webhook"
> >
{mutation.isPending ? "Saving..." : "Save Webhook"} {mutation.isPending ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -252,7 +252,7 @@ export function UserManagementTab() {
errors.email errors.email
? "border-error focus:border-error" ? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary" : "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`} } bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline_none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter email (optional)" placeholder="Enter email (optional)"
disabled={isCreating} disabled={isCreating}
/> />
@@ -302,15 +302,13 @@ export function UserManagementTab() {
<button <button
type="submit" type="submit"
disabled={isCreating} disabled={isCreating}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="px-4 py-2 bg-primary hover:bg-primary-hover text_white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Save User"
> >
{isCreating ? ( {isCreating ? (
<> <img src="/spinner.svg" alt="Saving" className="w-4 h-4 animate-spin logo" />
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating...
</>
) : ( ) : (
"Create User" <img src="/save.svg" alt="Save" className="w-4 h-4 logo" />
)} )}
</button> </button>
</div> </div>
@@ -474,14 +472,12 @@ export function UserManagementTab() {
type="submit" type="submit"
disabled={isResettingPassword} disabled={isResettingPassword}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Save Password"
> >
{isResettingPassword ? ( {isResettingPassword ? (
<> <img src="/spinner.svg" alt="Saving" className="w-4 h-4 animate-spin logo" />
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Resetting...
</>
) : ( ) : (
"Reset Password" <img src="/save.svg" alt="Save" className="w-4 h-4 logo" />
)} )}
</button> </button>
</div> </div>

View File

@@ -181,8 +181,13 @@ export function WatchTab() {
type="submit" type="submit"
disabled={mutation.isPending || !!validationError} disabled={mutation.isPending || !!validationError}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Watch Settings"
> >
{mutation.isPending ? "Saving..." : "Save Watch Settings"} {mutation.isPending ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -205,7 +205,7 @@ export const Album = () => {
? "Queued." ? "Queued."
: albumStatus === "error" : albumStatus === "error"
? "Download Album" ? "Download Album"
: "Downloading..." : <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin inline-block" />
: "Download Album"} : "Download Album"}
</button> </button>
</div> </div>

View File

@@ -300,7 +300,7 @@ export const Artist = () => {
? artistStatus === "queued" ? artistStatus === "queued"
? "Queued." ? "Queued."
: artistStatus === "downloading" : artistStatus === "downloading"
? "Downloading..." ? <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin" />
: <> : <>
<FaDownload className="icon-inverse" /> <FaDownload className="icon-inverse" />
<span>Download All</span> <span>Download All</span>
@@ -361,7 +361,7 @@ export const Artist = () => {
? "Queued." ? "Queued."
: trackStatuses[track.id] === "error" : trackStatuses[track.id] === "error"
? "Download" ? "Download"
: "Downloading..." : <img src="/spinner.svg" alt="Loading" className="w-4 h-4 animate-spin inline-block" />
: "Download"} : "Download"}
</button> </button>
</div> </div>

View File

@@ -239,7 +239,7 @@ export const Playlist = () => {
? "Queued." ? "Queued."
: playlistStatus === "error" : playlistStatus === "error"
? "Download All" ? "Download All"
: "Downloading..." : <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin inline-block" />
: "Download All"} : "Download All"}
</button> </button>
{settings?.watch?.enabled && ( {settings?.watch?.enabled && (
@@ -264,7 +264,7 @@ export const Playlist = () => {
{/* Tracks Section */} {/* Tracks Section */}
<div className="space-y-3 md:space-y-4"> <div className="space-y-3 md:space-y-4">
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify_between px-1">
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2> <h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2>
{tracks.length > 0 && ( {tracks.length > 0 && (
<span className="text-sm text-content-muted dark:text-content-muted-dark"> <span className="text-sm text-content-muted dark:text-content-muted-dark">
@@ -335,7 +335,7 @@ export const Playlist = () => {
? "Queued." ? "Queued."
: trackStatuses[track.id] === "error" : trackStatuses[track.id] === "error"
? <img src="/download.svg" alt="Download" className="w-4 h-4 logo" /> ? <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
: "Downloading..." : <img src="/spinner.svg" alt="Loading" className="w-4 h-4 animate-spin" />
: <img src="/download.svg" alt="Download" className="w-4 h-4 logo" /> : <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
} }
</button> </button>

View File

@@ -174,7 +174,7 @@ export const Track = () => {
style={{ width: `${track.popularity}%` }} style={{ width: `${track.popularity}%` }}
></div> ></div>
</div> </div>
<span className="text-sm font-medium text-content-secondary dark:text-content-secondary-dark"> <span className="text-sm font-medium text-content_secondary dark:text-content-secondary-dark">
{track.popularity}% {track.popularity}%
</span> </span>
</div> </div>
@@ -193,14 +193,14 @@ export const Track = () => {
? "Queued." ? "Queued."
: trackStatus === "error" : trackStatus === "error"
? "Download" ? "Download"
: "Downloading..." : <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin inline-block" />
: "Download"} : "Download"}
</button> </button>
<a <a
href={track.external_urls.spotify} href={track.external_urls.spotify}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="w-full sm:w-auto flex items-center justify-center gap-3 text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition duration-300 py-3 px-8 border border-border dark:border-border-dark rounded-full hover:border-border-accent dark:hover:border-border-accent-dark" className="w-full sm:w-auto flex items_center justify-center gap-3 text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition duration-300 py-3 px-8 border border-border dark:border-border-dark rounded-full hover:border-border-accent dark:hover:border-border-accent-dark"
aria-label="Listen on Spotify" aria-label="Listen on Spotify"
> >
<FaSpotify size={20} className="icon-secondary hover:icon-primary" /> <FaSpotify size={20} className="icon-secondary hover:icon-primary" />