feat(discord): add dismissible community banner

This commit is contained in:
ImAsra
2026-07-05 18:26:49 +02:00
committed by Psychotoxical
parent 9a556dfa12
commit 41ae30f05d
41 changed files with 515 additions and 2 deletions
+54
View File
@@ -41527,6 +41527,60 @@
} }
] ]
}, },
{
"type": "cycle",
"from": "src/store/authDiscordBannerActions.ts",
"to": "src/store/authStoreTypes.ts",
"unresolvedTo": "./authStoreTypes",
"dependencyTypes": [
"local",
"type-only",
"import"
],
"rule": {
"severity": "error",
"name": "no-circular"
},
"cycle": [
{
"name": "src/store/authStoreTypes.ts",
"dependencyTypes": [
"local",
"type-only",
"import"
]
},
{
"name": "src/music-network/index.ts",
"dependencyTypes": [
"local",
"type-only",
"import"
]
},
{
"name": "src/music-network/ui/useEnrichmentPrimary.ts",
"dependencyTypes": [
"local",
"export"
]
},
{
"name": "src/store/authStore.ts",
"dependencyTypes": [
"local",
"import"
]
},
{
"name": "src/store/authDiscordBannerActions.ts",
"dependencyTypes": [
"local",
"import"
]
}
]
},
{ {
"type": "cycle", "type": "cycle",
"from": "src/store/authDiscordSettingsActions.ts", "from": "src/store/authDiscordSettingsActions.ts",
+7
View File
@@ -40,6 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Full Bulgarian (Български) UI translation — selectable from the language picker on the Settings and Login screens. * Full Bulgarian (Български) UI translation — selectable from the language picker on the Settings and Login screens.
### Discord community banner
**By [@ImAsra](https://github.com/ImAsra), PR [#1222](https://github.com/Psychotoxical/psysonic/pull/1222)**
* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently.
## Changed ## Changed
### Frontend restructure — feature-folder architecture and hardening ### Frontend restructure — feature-folder architecture and hardening
+3
View File
@@ -57,6 +57,7 @@ import { AppShellQueueResizerSeam } from '@/app/AppShellQueueResizerSeam';
import { IS_LINUX, IS_MACOS } from '@/lib/util/platform'; import { IS_LINUX, IS_MACOS } from '@/lib/util/platform';
import { useConnectionStatus } from '@/lib/hooks/useConnectionStatus'; import { useConnectionStatus } from '@/lib/hooks/useConnectionStatus';
import { useIdlePlayQueuePull } from '@/app/hooks/useIdlePlayQueuePull'; import { useIdlePlayQueuePull } from '@/app/hooks/useIdlePlayQueuePull';
import { DiscordBanner, useAccumulatedUsage } from '@/features/discordBanner';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore'; import { usePlayerStore } from '@/features/playback/store/playerStore';
import '@/features/playback/store/previewPlayerVolumeSync'; import '@/features/playback/store/previewPlayerVolumeSync';
@@ -92,6 +93,7 @@ export function AppShell() {
usePlaybackRateOrbitSync(); usePlaybackRateOrbitSync();
useTrayMenuI18n(); useTrayMenuI18n();
useServerCapabilitiesProbe(); useServerCapabilitiesProbe();
useAccumulatedUsage();
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen); const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
@@ -282,6 +284,7 @@ export function AppShell() {
)} )}
</header> </header>
<OrbitSessionBar /> <OrbitSessionBar />
<DiscordBanner/>
{connStatus === 'disconnected' && ( {connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} /> <OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
)} )}
+1
View File
@@ -411,6 +411,7 @@ const CONTRIBUTOR_ENTRIES = [
contributions: [ contributions: [
'Long-press album Play to shuffle with hold progress animation (PR #888)', 'Long-press album Play to shuffle with hold progress animation (PR #888)',
'WinGet release automation — GitHub Actions workflow that opens manifest PRs on each release (PR #1077)', 'WinGet release automation — GitHub Actions workflow that opens manifest PRs on each release (PR #1077)',
'Dismissible Discord community banner (PR #1222)',
], ],
}, },
{ {
@@ -0,0 +1,50 @@
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { open } from '@tauri-apps/plugin-shell';
import { useDiscordBanner } from './useDiscordBanner';
const DISCORD_INVITE = 'https://discord.gg/AMnDRErm4u';
export default function DiscordBanner() {
const { t } = useTranslation();
const { visible, dismiss } = useDiscordBanner();
if (!visible) return null;
const handleJoin = (): void => {
void open(DISCORD_INVITE);
dismiss(false);
};
return (
<div className='discord-banner' role='region' aria-label={t('discordBanner.ariaLabel')}>
<div className='discord-banner-left'>
<svg className='discord-banner-icon' viewBox='0 0 24 24' fill='currentColor' aria-hidden='true'>
<path d='M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z' />
</svg>
<span className='discord-banner-text'>{t('discordBanner.message')}</span>
<button type='button' className='discord-banner-join' onClick={handleJoin}>
{t('discordBanner.join')}
</button>
</div>
<div className='discord-banner-right'>
<button
type='button'
className='discord-banner-never'
onClick={() => dismiss(true)}
>
{t('discordBanner.neverShow')}
</button>
<button
type='button'
className='discord-banner-close'
onClick={() => dismiss(false)}
aria-label={t('discordBanner.close')}
>
<X size={14} />
</button>
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as DiscordBanner } from './DiscordBanner';
export { useAccumulatedUsage } from './useAccumulatedUsage';
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAccumulatedUsage } from './useAccumulatedUsage';
import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
describe('useAccumulatedUsage', () => {
beforeEach(() => {
resetAuthStore();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
resetAuthStore();
});
it('accumulates elapsed time into the auth store on each tick', () => {
renderHook(() => useAccumulatedUsage());
expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(0);
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(30_000);
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(60_000);
});
it('stops accumulating after unmount', () => {
const { unmount } = renderHook(() => useAccumulatedUsage());
act(() => {
vi.advanceTimersByTime(30_000);
});
const afterFirstTick = useAuthStore.getState().discordBannerAccumulatedUsageMs;
unmount();
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(afterFirstTick);
});
});
@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';
import { useAuthStore } from '@/store/authStore';
const TICK_MS = 30_000; // flush every 30s
/**
* Tracks total accumulated app usage time across sessions, persisted via
* authStore (zustand persist → localStorage). Counts time for as long as
* the app process is running, regardless of window focus/visibility.
* Mount once near the app root (e.g. AppShell).
*/
export function useAccumulatedUsage(): void {
const lastTickRef = useRef<number | null>(null);
const addUsageMs = useAuthStore(s => s.addDiscordBannerUsageMs);
useEffect(() => {
lastTickRef.current = Date.now();
const flush = (): void => {
const now = Date.now();
const delta = now - (lastTickRef.current ?? now);
lastTickRef.current = now;
addUsageMs(delta);
};
const interval = window.setInterval(flush, TICK_MS);
return () => {
window.clearInterval(interval);
};
}, [addUsageMs]);
}
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDiscordBanner } from './useDiscordBanner';
import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
const THRESHOLD_MS = 20 * 60 * 60 * 1000; // matches useDiscordBanner
describe('useDiscordBanner', () => {
beforeEach(resetAuthStore);
afterEach(resetAuthStore);
it('stays hidden until accumulated usage reaches the threshold', () => {
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS - 1 });
const { result } = renderHook(() => useDiscordBanner());
expect(result.current.visible).toBe(false);
});
it('becomes visible at the threshold when never dismissed', () => {
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
const { result } = renderHook(() => useDiscordBanner());
expect(result.current.visible).toBe(true);
});
it('stays hidden when permanently dismissed even far past the threshold', () => {
useAuthStore.setState({
discordBannerAccumulatedUsageMs: THRESHOLD_MS * 2,
discordBannerDismissed: true,
});
const { result } = renderHook(() => useDiscordBanner());
expect(result.current.visible).toBe(false);
});
it('hides for the session on a non-permanent dismiss without persisting', () => {
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
const { result } = renderHook(() => useDiscordBanner());
expect(result.current.visible).toBe(true);
act(() => result.current.dismiss(false));
expect(result.current.visible).toBe(false);
expect(useAuthStore.getState().discordBannerDismissed).toBe(false);
});
it('persists a permanent dismiss to the auth store', () => {
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
const { result } = renderHook(() => useDiscordBanner());
act(() => result.current.dismiss(true));
expect(result.current.visible).toBe(false);
expect(useAuthStore.getState().discordBannerDismissed).toBe(true);
});
});
@@ -0,0 +1,25 @@
import { useState } from 'react';
import { useAuthStore } from '@/store/authStore';
const USAGE_THRESHOLD_MS = 20 * 60 * 60 * 1000; // 20 hours
interface UseDiscordBannerReturn {
visible: boolean;
dismiss: (permanent: boolean) => void;
}
export function useDiscordBanner(): UseDiscordBannerReturn {
const dismissedPermanently = useAuthStore(s => s.discordBannerDismissed);
const usageMs = useAuthStore(s => s.discordBannerAccumulatedUsageMs);
const dismissDiscordBanner = useAuthStore(s => s.dismissDiscordBanner);
const [sessionDismissed, setSessionDismissed] = useState(false);
const visible = !dismissedPermanently && !sessionDismissed && usageMs >= USAGE_THRESHOLD_MS;
const dismiss = (permanent: boolean): void => {
if (permanent) dismissDiscordBanner();
setSessionDismissed(true);
};
return { visible, dismiss };
}
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Присъединете се към Psysonic в Discord',
message: 'Присъединете се към общността на Psysonic в Discord',
join: 'Присъединяване',
neverShow: 'Да не се показва повече',
close: 'Скриване',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const bgTranslation = { export const bgTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const bgTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Tritt Psysonic auf Discord bei',
message: 'Tritt der Psysonic-Community auf Discord bei',
join: 'Beitreten',
neverShow: 'Nie wieder anzeigen',
close: 'Verwerfen',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const deTranslation = { export const deTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const deTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Join Psysonic on Discord',
message: 'Join the Psysonic community on Discord',
join: 'Join',
neverShow: 'Never show again',
close: 'Dismiss',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const enTranslation = { export const enTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const enTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Únete a Psysonic en Discord',
message: 'Únete a la comunidad de Psysonic en Discord',
join: 'Unirse',
neverShow: 'No volver a mostrar',
close: 'Descartar',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const esTranslation = { export const esTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const esTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Rejoindre Psysonic sur Discord',
message: 'Rejoignez la communauté Psysonic sur Discord',
join: 'Rejoindre',
neverShow: 'Ne plus afficher',
close: 'Ignorer',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const frTranslation = { export const frTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const frTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Csatlakozz a Psysonic-hoz Discordon',
message: 'Csatlakozz a Psysonic közösséghez Discordon',
join: 'Csatlakozás',
neverShow: 'Soha többé ne mutasd',
close: 'Elvetés',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const huTranslation = { export const huTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const huTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'DiscordでPsysonicに参加',
message: 'DiscordのPsysonicコミュニティに参加しよう',
join: '参加する',
neverShow: '二度と表示しない',
close: '閉じる',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const jaTranslation = { export const jaTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const jaTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Bli med Psysonic på Discord',
message: 'Bli med i Psysonic-samfunnet på Discord',
join: 'Bli med',
neverShow: 'Aldri vis igjen',
close: 'Avvis',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const nbTranslation = { export const nbTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const nbTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Word lid van Psysonic op Discord',
message: 'Word lid van de Psysonic-community op Discord',
join: 'Deelnemen',
neverShow: 'Nooit meer tonen',
close: 'Negeren',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const nlTranslation = { export const nlTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const nlTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Dołącz do Psysonic na Discordzie',
message: 'Dołącz do społeczności Psysonic na Discordzie',
join: 'Dołącz',
neverShow: 'Nie pokazuj ponownie',
close: 'Odrzuć',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const plTranslation = { export const plTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const plTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Alătură-te lui Psysonic pe Discord',
message: 'Alătură-te comunității Psysonic pe Discord',
join: 'Alătură-te',
neverShow: 'Nu mai arăta niciodată',
close: 'Omite',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const roTranslation = { export const roTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const roTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: 'Присоединяйтесь к Psysonic в Discord',
message: 'Присоединяйтесь к сообществу Psysonic в Discord',
join: 'Присоединиться',
neverShow: 'Больше не показывать',
close: 'Скрыть',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const ruTranslation = { export const ruTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const ruTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+7
View File
@@ -0,0 +1,7 @@
export const discordBanner = {
ariaLabel: '在 Discord 上加入 Psysonic',
message: '加入 Discord 上的 Psysonic 社区',
join: '加入',
neverShow: '不再显示',
close: '忽略',
};
+2
View File
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration'; import { migration } from './migration';
import { discordBanner } from './discordBanner';
export const zhTranslation = { export const zhTranslation = {
sidebar, sidebar,
@@ -92,4 +93,5 @@ export const zhTranslation = {
tray, tray,
licenses, licenses,
migration, migration,
discordBanner,
}; };
+20
View File
@@ -0,0 +1,20 @@
// src/store/authDiscordBannerActions.ts
import type { AuthState } from './authStoreTypes';
export interface DiscordBannerActions {
dismissDiscordBanner: () => void;
addDiscordBannerUsageMs: (deltaMs: number) => void;
}
export const createDiscordBannerActions = (
set: (fn: (state: AuthState) => Partial<AuthState>) => void
): DiscordBannerActions => ({
dismissDiscordBanner: () =>
set(() => ({ discordBannerDismissed: true })),
addDiscordBannerUsageMs: deltaMs =>
set(state => ({
discordBannerAccumulatedUsageMs: state.discordBannerAccumulatedUsageMs + deltaMs,
})),
});
+4 -2
View File
@@ -24,12 +24,13 @@ import { syncAllServerHttpContexts } from '@/lib/server/syncServerHttpContext';
import type { AuthState } from './authStoreTypes'; import type { AuthState } from './authStoreTypes';
import { getCachedConnectBaseUrl } from '@/lib/server/serverEndpoint'; import { getCachedConnectBaseUrl } from '@/lib/server/serverEndpoint';
import { serverProfileBaseUrl } from '@/lib/server/serverBaseUrl'; import { serverProfileBaseUrl } from '@/lib/server/serverBaseUrl';
import { createDiscordBannerActions } from './authDiscordBannerActions';
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
discordBannerDismissed: false,
discordBannerAccumulatedUsageMs: 0,
servers: [], servers: [],
activeServerId: null, activeServerId: null,
musicNetworkAccounts: [], musicNetworkAccounts: [],
@@ -148,6 +149,7 @@ export const useAuthStore = create<AuthState>()(
...createSkipStarActions(set, get), ...createSkipStarActions(set, get),
...createMusicLibraryActions(set, get), ...createMusicLibraryActions(set, get),
...createPerServerCapabilityActions(set), ...createPerServerCapabilityActions(set),
...createDiscordBannerActions(set),
getBaseUrl: () => { getBaseUrl: () => {
const s = get(); const s = get();
+6
View File
@@ -109,6 +109,12 @@ export type TrackPreviewLocation =
export type TrackPreviewLocations = Record<TrackPreviewLocation, boolean>; export type TrackPreviewLocations = Record<TrackPreviewLocation, boolean>;
export interface AuthState { export interface AuthState {
// DiscordBanner
discordBannerDismissed: boolean;
discordBannerAccumulatedUsageMs: number;
dismissDiscordBanner: () => void;
addDiscordBannerUsageMs: (deltaMs: number) => void;
// Multi-server // Multi-server
servers: ServerProfile[]; servers: ServerProfile[];
activeServerId: string | null; activeServerId: string | null;
+93
View File
@@ -0,0 +1,93 @@
/* src/styles/layout/discord-banner.css */
.discord-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px;
height: 36px;
background: #5865f2;
color: #fff;
font-size: 13px;
flex-shrink: 0;
z-index: 50;
}
.discord-banner-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.discord-banner-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
letter-spacing: 0.01em;
}
.discord-banner-join {
display: inline-flex;
align-items: center;
padding: 2px 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: 12px;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.35);
white-space: nowrap;
cursor: pointer;
transition: background 0.15s;
}
.discord-banner-join:hover {
background: rgba(255, 255, 255, 0.28);
}
.discord-banner-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.discord-banner-never {
padding: 2px 10px;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.75);
font-size: 11px;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.25);
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, background 0.15s;
}
.discord-banner-never:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.discord-banner-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
border: none;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.discord-banner-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.15);
}
+1
View File
@@ -29,3 +29,4 @@
@import './empty-state.css'; @import './empty-state.css';
@import './utility-footer.css'; @import './utility-footer.css';
@import './sidebar-expandable-playlists-section.css'; @import './sidebar-expandable-playlists-section.css';
@import './discord-banner.css';